addDateTaken ist pythonisiert
This commit is contained in:
parent
6d78c328c6
commit
077f490687
3 changed files with 391 additions and 2 deletions
|
@ -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)
|
||||
|
|
|
@ -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
389
addDateTaken.py
Executable 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)
|
Loading…
Reference in a new issue