diff --git a/README-de.md b/README-de.md index 79edaa8..3ad18ae 100644 --- a/README-de.md +++ b/README-de.md @@ -1,7 +1,7 @@ # Felix' Internetauftritt * [Tricks und Anleitungen, gesammelt, von Felix; hauptsächlich für Linux](Tricks.md) - * [ein Shell-Skript zum umbenennen von Bildern, wobei das Datum als Dateiname genutzt wird](addDateTaken.sh) + * [ein Python-Skript zum umbenennen von Bildern, wobei das Datum als Dateiname genutzt wird](addDateTaken.py) * [ein Python-Skript zum Heraussuchen von den Beschreibungen von OSM-Notes aus .osn-Dateien, siehe auch "Tricks und Anleitungen"](extract-hinweise.py) * [ein Shell-Skript zum Finden und auflisten aller symlinks in einem Ordner inklusive verlinktem Ort](listLinks.sh) * [ein Shell-Skript zum Automatischen Umbenennen von Dateien (entfernen von nervigen Sonderzeichen)](removeBadSymbols.sh) diff --git a/README.md b/README.md index 26609f3..6e465a2 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [Für deutsche Version hier klicken.](README-de.md) * [Tricks and tutorials, collected by Felix, mainly for Linux (german)](Tricks.md) - * [`.sh`-script for renaming pictures. Use date for filename.](addDateTaken.sh) + * [python-script for renaming pictures. Use date for filename.](addDateTaken.py) * [Python script for getting descriptions of OSM-notes from `.osn`-files. Also see "Tricks and tutorials"](extract-hinweise.py) * [`.sh`-script for finding and listing all symlinks inkluding the linked paths](listLinks.sh) * [python script for automatic renaming of files for removal of annoying symbols](removeBadSymbols.py) diff --git a/addDateTaken.py b/addDateTaken.py new file mode 100755 index 0000000..30e7b47 --- /dev/null +++ b/addDateTaken.py @@ -0,0 +1,389 @@ +#! /usr/bin/env python3 +# -*- coding: utf-8 -*- + +"""Rename image files with dates. + +# options +# -h --help display help +# -a --noask --no-interactive do not prompt the user to confirm renaming +# -l --onlylog just logs what would have been done but not doing anything +# -o [file] --outputfile [file] specifies the output (log) file (standard is da.log) +# -v [file] --voutputfile [file] specifies the verbose output log file (standard dav.log) +# -q --quiet do not log +""" + +from argparse import ArgumentParser, ArgumentDefaultsHelpFormatter +import logging +# import re as regex +import os +import os.path +import exifread +import subprocess +import re + +LOGGING_LEVELS = {"debug": logging.DEBUG, + "info": logging.INFO, + "warning": logging.WARNING, + "error": logging.ERROR + } + +REPLACER = "_" +INVALID_NAMES = ["", ".", ".."] +CAMERA_REGEXES = [ + r"SAM_\d{4}", + r"SVN_\d{5}", + r"DSC[_\dFIN]\d{4}", + r"IMG_\d{8}_\d{6}(_\d{3})?", + r"R?IMGP?[_\d]{4,5}", + r"\d{3}-\d{4}_IMG", + r"(IMG|VID|PXL)_20\d{6}_\d{6,9}" + + r"(.PANO|.NIGHT|.LS|.MP|.PORTRAIT|.MOTION-\d\d.ORIGINAL)?", + # here one could extract the date and compare it with + # the exif data and issue a warning if different + r"P[ABC\d]\d{6}", + r"PICT\d{4}", + r"SDC\d{5}", + r"\d{13}", + r"\d{14}_0M.{6}--\.\d", + r"0.{7}--\.\d", + r"0CLx.{6}--\.\d", + r"\d{8}_\d{6}(_HDR)?(~2)?", + r"Foto\d{4}", + r"\d{8,9}_\d{6}", + r"[\dA-Z]{8}-([\dA-Z]{4,5}-){3}[\dA-Z]{12}", + r"D7C_\d{4}", + r"_MG_\d{4}", + r"\d{5}" # only with MTS at the end but this cannot be detected + # by the regex, can it? +] +CAMERA_REGEXES = [re.compile(regex) for regex in CAMERA_REGEXES] +DATE_REGEX = re.compile(r'20\d{2}_\d{2}_\d{2}') +DATE_IN_FILENAME_REGEXES = [ + r"signal-(?P20\d{2})-(?P[01]\d)-(?P[0123]\d)-(?P" + + r"\d{2})(?P\d{2})(?P\d{2})(?P.*)", + # telegram: + r"photo_(?P20\d{2})-(?P[01]\d)-(?P[0123]\d)_(?P" + + r"\d{2})-(?P\d{2})-(?P\d{2})(?P.*)", + # Camera from my Fairphone 3 with DivestOS: + r"(?P20\d{2})_(?P[01]\d)_(?P[0123]\d)_" + + r"(?P\d{2})(?P\d{2})(?P\d{2})_" + + r"\d{4}-(\d{2}-){5}\d{3}(?P.*)" +] +DATE_IN_FILENAME_REGEXES = [re.compile(regex) for regex in + DATE_IN_FILENAME_REGEXES] +LOGGER = logging.getLogger('addDates') +# not used: +EXIF_TAGS = {'.jpg': ("EXIF DateTimeOriginal", ["Image DateTime"]), + '.JPG': ("EXIF DateTimeOriginal", ["Image DateTime"]), + '.JPEG': ("EXIF DateTimeOriginal", ["Image DateTime"]), + '.jpeg': ("EXIF DateTimeOriginal", ["Image DateTime"]), + '.mov': ("EXIF DateTimeOriginal", ["Image DateTime"]), + '.MOV': ("EXIF DateTimeOriginal", ["Image DateTime"]), + '.mp4': ("EXIF DateTimeOriginal", ["Image DateTime"]), + '.mts': ("EXIF DateTimeOriginal", ["Image DateTime"]), + '.MTS': ("EXIF DateTimeOriginal", ["Image DateTime"])} + +def parse_args(): + """Parse command line arguments. + + Returns: + Dictionary with the command line arguments. + + """ + parser = ArgumentParser( + description="""Using the date the picture or movie was taken for file name. + + Which files are renamed is logged in a log file. + (See below in --log.)""", + formatter_class=ArgumentDefaultsHelpFormatter + ) + parser.add_argument( + "-a", "--noask", "--no-interactive", + dest="ask", + help=("Specify if renaming should not be confirmed by the user" + + " but just done."), + action="store_false" + ) + parser.add_argument( + "-l", "--onlylog", + dest="rename", + action="store_false", + help="Dry run." + ) + parser.add_argument( + "--log", "--logfile", + dest="log", + metavar="L", + help="""the file where to write the logging output. + This file is overwritten if existing. + If not specified assume 'rename.log'""", + action="store", + default=os.path.join("..", "rename.log") + ) + parser.add_argument( + "--loglevel", "--level", + dest="loglevel", + help="""Specify the log level. + 'error' includes OS errors at renaming files. + 'warning' additionally includes no-renames because of weird resulting file name. + 'info' includes no-renames because of existing files and renames and working directories. + 'debug' includes good files.""", + default="debug", + choices=LOGGING_LEVELS.keys() + ) + parser.add_argument( + "-q", "--quiet", + dest="quiet", + help="Set log level to warning, so usually do not create a log file.", + action="store_true" + ) + parser.add_argument( + nargs="?", + dest="top", + metavar="directory", + help="""The directory in which to rename files.""", + action="store", + default=".") + args = parser.parse_args() + if args.quiet: + args.loglevel = "warning" + return args + + +def remove_camerastring(name: str): + """Remove all bad symbols from name.""" + for regex in CAMERA_REGEXES: + match = re.match(regex, name) + if match: + LOGGER.debug("Pattern {pattern} is found and left {rest}".format( + pattern=regex.pattern, rest=name[match.end():])) + return name[match.end():] + if re.match(DATE_REGEX, name): + LOGGER.debug("File '{}' already has the date.".format(name)) + return None + else: + LOGGER.warning( + "File '{}' does not have date nor is from a recognized camera.".format(name)) + return name + + +def get_date_taken(directory, file): + """Get the date the file was taken at in nice format. + + Format is YYYY_MM_DD_HHMMSS. + """ + path = os.path.join(directory, file) + try: + image = open(path, 'rb') # r=read-only, b=binary + except FileNotFoundError: + LOGGER.warning("File '{}' not found.".format(os.path.join(directory, file))) + return None + except Exception as error: + LOGGER.warning( + "Error while opening file '{}' Please improve accurary of script with this error:\n{}".format( + path, error)) + return None + else: + with image: + exif = exifread.process_file(image, details=False) + if len(exif) == 0: + # no exif data readable by python, probably MOV file + exifresult = subprocess.run( + ['exiftool', '-dateFormat', '%Y_%m_%d_%H%M%S', + '-DateTimeOriginal', path], + # -DateTimeOriginal works for MTS files from my camera + # and one mov file I found on my computer + # while -CreateDate works only with the mov file + # If in the future there are files with only + # -CreateDate or -DateTimeOriginal is wrong, a more + # sophisticated method is needed. + # '-CreateDate', path], + capture_output=True) + output = exifresult.stdout.decode('utf-8', 'backslashreplace').strip() + LOGGER.debug("exiftool result for '{}': '{}'".format(path, output)) + try: + date = output.split(":")[1].strip() + except (ValueError, IndexError): + LOGGER.info("File '{}' has no exif data.".format(path)) + return None + else: + if date == "0000": # no actual data + LOGGER.info(("File '{}' has no meaningful " + + "creation date.").format(path)) + return None + return date + # Debug: print("\n".join("'{}'\t'{}'".format(k, v) for k, v in exif.items())) + try: + date_as_str = str(exif["EXIF DateTimeOriginal"]) + except KeyError: + try: + date_as_str = str(exif["Image DateTime"]) + except KeyError: + LOGGER.warning("Image '{}' has no creation date.".format(path)) + return None + else: + LOGGER.warning("Image '{}' has no 'EXIF DateTimeOriginal'. So take 'Image DateTime'.".format( + path + )) + # no creation date + # now either returned or date_as_str exists + logging.debug("That's the date for '{}': '{}'".format(path, date_as_str)) + if date_as_str == "0000:00:00 00:00:00": + LOGGER.info("Image '{}' has no meaningful creation date.".format(path)) + return None + try: + date, time = date_as_str.split(" ") + except ValueError: + LOGGER.error("File '{}' has invalid datetime format: '{}'".format( + path, date_as_str + )) + return None + except AttributeError as attrerror: + LOGGER.error("File '{}' has a problem: with the following exif data: '{}'\n'{}'".format( + path, + "\n".join("'{}'\t'{}'".format(k, v) for k, v in exif.items()), + attrerror + )) + raise + try: + return "_".join(date.split(":")) + "_" + "".join(time.split(":")) + except ValueError: + LOGGER.error("File '{}' has invalid date or time format: '{}'".format( + path, date_as_str + )) + + +def get_date_and_extra_from_filename(file): + """Return date if it's coded into filename. + + This makes sense if there is no suitable exif data. + + Returns: + None if no date is found. Otherwise: + date in YYYY_MM_DD_hhmmss + shortened: the file name without the date part + """ + template = r"\g_\g_\g_\g\g\g" + for regex in DATE_IN_FILENAME_REGEXES: + match = re.match(regex, file) + if match: + date = match.expand(template) + LOGGER.debug("Found datetime in filename {file}: {date}".format( + file=file, date=date)) + return (date, match.group("extra")) + LOGGER.info("No date found in filename {file}".format(file=file)) + return None + + +def rename_file(directory, file, args): + """Rename file directory/file if renaming is OK.""" + date = get_date_taken(directory, file) + LOGGER.debug('got date {} for file {}'.format(date, file)) + if date is None: + # logging already happened + date_shortened = get_date_and_extra_from_filename(file) + if date_shortened is None: + # logging already happened + return + date, shortened = date_shortened + else: + shortened = remove_camerastring(file) + if shortened is None: + # date is already there, logging has happened + return + new_name = ( + date + + ("" if shortened.startswith("_") or shortened.startswith(".") else "_") + + shortened.replace('.JPEG', '.jpg').replace('.jpeg', '.jpg').replace( + '.JPG', '.jpg').replace('.MOV', '.mov').replace(".MTS", ".mts") + ) + if new_name == file: + LOGGER.debug("'{}' is OK.".format(file)) + return + path = os.path.join(directory, file) + new_path = os.path.join(directory, new_name) + if os.path.lexists(new_path): + LOGGER.info("'{}' is not renamed to '{}' because this already exists.".format( + file, new_name + )) + return + if new_name in INVALID_NAMES: + LOGGER.warning("'{}' is not renamed because it would invalid: '{}'.".format( + path, new_name)) + return + if args.ask: + rename = input("Rename '{}' to '{}'? (Enter for yes)".format( + path, new_name + )) + rename = not rename.lower().startswith("n") + else: + rename = True + if rename: + if args.rename: # == not onlylog + try: + os.rename(path, new_path) + except FileNotFoundError as error: + LOGGER.error("Could not move '{}' to '{}' due to FileNotFoundError: {}".format( + path, new_name, error + )) + LOGGER.info("Rename '{}' to '{}'".format(file, new_name)) + else: + LOGGER.info("Did not rename '{}' to '{}' due to user choice.".format( + file, new_name + )) + + +def if_ignore_dir(dirname): + """Return if this directory name should be ignored. + + This means that the directory content is ignored as well.""" + return ( + dirname.startswith('.') or + dirname == '__pycache__' or ( + dirname.startswith('__') and ( # special python file + dirname.endswith('__.py') or + dirname.endswith('__.html') or + dirname.endswith('__.txt') + )) or dirname.endswith('.class')) # java class file + +def if_ignore_file(filename): + """Return if this file should be ignored.""" + return ( + filename.startswith('.') or not ( + filename.endswith('.jpeg') or + filename.endswith('.jpg') or + filename.endswith('.JPEG') or + filename.endswith('.JPG') or + filename.endswith('.png') or + filename.endswith('.PNG') or + filename.endswith('.mov') or + filename.endswith('.MOV') or + filename.endswith('.mp4') or + filename.endswith('.mkv') or + filename.endswith('.mts') or + filename.endswith('.MTS') + ) + ) +if __name__ == "__main__": + args = parse_args() + logging.basicConfig( + filename=args.log, + ) + LOGGER.setLevel(LOGGING_LEVELS[args.loglevel]) + for dirpath, dirnames, filenames in os.walk(args.top, topdown=True): + print('.', end='') + LOGGER.info("Go to directory '{}'".format(os.path.abspath(dirpath))) + for dir in dirnames: + if if_ignore_dir(dir): + LOGGER.debug("Ignore dir '{}'".format(dir)) + dirnames.remove(dir) + for file in filenames: + if if_ignore_file(file): + LOGGER.debug("Ignore file '{}'".format(file)) + else: + rename_file(dirpath, file, args) + + # remove empty log file: + logging.shutdown() + if os.stat(args.log).st_size == 0: + os.remove(args.log)