addDateTaken ist pythonisiert

This commit is contained in:
flukx 2024-06-30 19:45:55 +02:00
parent 6d78c328c6
commit 077f490687
3 changed files with 391 additions and 2 deletions

View file

@ -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)

View file

@ -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)

389
addDateTaken.py Executable file
View file

@ -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-(?P<year>20\d{2})-(?P<month>[01]\d)-(?P<day>[0123]\d)-(?P<hour>"
+ r"\d{2})(?P<min>\d{2})(?P<sec>\d{2})(?P<extra>.*)",
# telegram:
r"photo_(?P<year>20\d{2})-(?P<month>[01]\d)-(?P<day>[0123]\d)_(?P<hour>"
+ r"\d{2})-(?P<min>\d{2})-(?P<sec>\d{2})(?P<extra>.*)",
# Camera from my Fairphone 3 with DivestOS:
r"(?P<year>20\d{2})_(?P<month>[01]\d)_(?P<day>[0123]\d)_"
+ r"(?P<hour>\d{2})(?P<min>\d{2})(?P<sec>\d{2})_"
+ r"\d{4}-(\d{2}-){5}\d{3}(?P<extra>.*)"
]
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<year>_\g<month>_\g<day>_\g<hour>\g<min>\g<sec>"
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)