Set file modification time for exporting images
This commit is contained in:
+221
-47
@@ -24,6 +24,7 @@ python3 piwigo_export.py ... --metadata exif iptc xmp
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import datetime
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
@@ -198,6 +199,46 @@ def _xmp_datetime(s) -> str:
|
||||
return f'{s[:10]}T{t}'
|
||||
|
||||
|
||||
def _parse_datetime(s) -> datetime.datetime | None:
|
||||
"""Parse a DB or EXIF date string into a datetime, or return None."""
|
||||
s = str(s).strip()
|
||||
for fmt in ('%Y-%m-%d %H:%M:%S', '%Y-%m-%d',
|
||||
'%Y:%m:%d %H:%M:%S', '%Y:%m:%d'):
|
||||
try:
|
||||
return datetime.datetime.strptime(s[:len(fmt)], fmt)
|
||||
except ValueError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
# EXIF/IPTC/XMP tags that carry a capture or creation date, in preference order.
|
||||
_DATE_TAGS_IN_IMAGE = (
|
||||
'EXIF:DateTimeOriginal',
|
||||
'EXIF:CreateDate',
|
||||
'XMP-xmp:CreateDate',
|
||||
'IPTC:DateCreated',
|
||||
'EXIF:ModifyDate',
|
||||
)
|
||||
|
||||
|
||||
def _earliest_image_date(image_path: pathlib.Path) -> datetime.datetime | None:
|
||||
"""Return the earliest datetime found in the image's embedded metadata.
|
||||
|
||||
Returns None if exiftool is not available or no date tags are found.
|
||||
"""
|
||||
if not shutil.which('exiftool'):
|
||||
return None
|
||||
existing = _read_existing_metadata(image_path)
|
||||
dates = []
|
||||
for tag in _DATE_TAGS_IN_IMAGE:
|
||||
val = existing.get(tag)
|
||||
if val:
|
||||
dt = _parse_datetime(str(val))
|
||||
if dt:
|
||||
dates.append(dt)
|
||||
return min(dates) if dates else None
|
||||
|
||||
|
||||
def _build_metadata_tags(metadata: dict, fmt: str) -> dict:
|
||||
"""
|
||||
Build a dict of { 'GROUP:TagName': value } for everything we want to write.
|
||||
@@ -469,11 +510,15 @@ def export_image(
|
||||
tags = tags_by_image.get(image_id, [])
|
||||
cat_ids = cats_by_image.get(image_id, [])
|
||||
|
||||
# Collect both date sources before building the metadata dict.
|
||||
date_embedded_dt = _earliest_image_date(src_file)
|
||||
|
||||
metadata = {
|
||||
'title': image_row.get('name'),
|
||||
'author': image_row.get('author'),
|
||||
'date_created': str(image_row['date_creation']) if image_row.get('date_creation') else None,
|
||||
'date_added': str(image_row['date_available']) if image_row.get('date_available') else None,
|
||||
'date_embedded': str(date_embedded_dt) if date_embedded_dt else None,
|
||||
'description': image_row.get('comment'),
|
||||
'tags': tags,
|
||||
'albums': [category_display_path(cid, categories) for cid in cat_ids],
|
||||
@@ -486,6 +531,12 @@ def export_image(
|
||||
'original_path': image_row['path'],
|
||||
}
|
||||
|
||||
# Print both date sources so the user can see any discrepancy.
|
||||
piwigo_str = metadata['date_created'] or '—'
|
||||
embedded_str = metadata['date_embedded'] or '—'
|
||||
filename_str = pathlib.Path(image_row['path']).name
|
||||
print(f' {filename_str} piwigo: {piwigo_str} embedded: {embedded_str}')
|
||||
|
||||
dest_dirs = (
|
||||
[output_dir / category_fs_path(cid, categories) for cid in cat_ids]
|
||||
if cat_ids
|
||||
@@ -539,53 +590,8 @@ def export_image(
|
||||
# Entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description='Export Piwigo photos with JSON metadata sidecars.',
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog=__doc__,
|
||||
)
|
||||
|
||||
db = parser.add_argument_group('database')
|
||||
db.add_argument('--dbhost', metavar='HOST')
|
||||
db.add_argument('--dbuser', required=True, metavar='USER')
|
||||
db.add_argument('--dbpassword', metavar='PASS')
|
||||
db.add_argument('--dbname', required=True, metavar='NAME')
|
||||
db.add_argument(
|
||||
'--db-prefix', default='piwigo_', metavar='PREFIX',
|
||||
help='Piwigo table prefix (default: %(default)s)',
|
||||
)
|
||||
|
||||
io = parser.add_argument_group('paths')
|
||||
io.add_argument(
|
||||
'--src-path', required=True, metavar='DIR',
|
||||
help='Root of the Piwigo installation; piwigo_images.path is relative to this.',
|
||||
)
|
||||
io.add_argument(
|
||||
'--output-dir', required=True, metavar='DIR',
|
||||
help='Directory to write exported files into (created if absent).',
|
||||
)
|
||||
|
||||
behaviour = parser.add_argument_group('behaviour')
|
||||
behaviour.add_argument(
|
||||
'--metadata', choices=['exif', 'iptc', 'xmp'], nargs='+', metavar='FORMAT',
|
||||
help='Also embed metadata into the exported image copy using exiftool. '
|
||||
'One or more of: exif, iptc, xmp. '
|
||||
'Example: --metadata exif iptc xmp',
|
||||
)
|
||||
behaviour.add_argument(
|
||||
'--overwrite', action='store_true',
|
||||
help='Re-export image files that already exist in the output directory. '
|
||||
'JSON sidecars are always refreshed.',
|
||||
)
|
||||
behaviour.add_argument(
|
||||
'--no-overwrite-metadata', action='store_true',
|
||||
help='When embedding metadata, never overwrite a tag that already has a '
|
||||
'value in the file — skip it silently instead of prompting.',
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
def cmd_export(args):
|
||||
"""Run the export subcommand (database → filesystem)."""
|
||||
if args.metadata:
|
||||
check_exiftool()
|
||||
|
||||
@@ -646,5 +652,173 @@ def main():
|
||||
)
|
||||
|
||||
|
||||
def cmd_set_dates(args):
|
||||
"""Walk an export directory and set each image's mtime from its sidecar dates."""
|
||||
output_dir = pathlib.Path(args.output_dir)
|
||||
if not output_dir.is_dir():
|
||||
sys.exit(f'ERROR: output directory not found: {output_dir}')
|
||||
|
||||
sidecars = sorted(output_dir.rglob('*.json'))
|
||||
if not sidecars:
|
||||
print('No JSON sidecars found.')
|
||||
return
|
||||
|
||||
applied = skipped = noted = 0
|
||||
|
||||
for sidecar in sidecars:
|
||||
# Load the sidecar.
|
||||
try:
|
||||
data = json.loads(sidecar.read_text(encoding='utf-8'))
|
||||
except (json.JSONDecodeError, OSError) as exc:
|
||||
print(f'WARNING: could not read {sidecar}: {exc}', file=sys.stderr)
|
||||
continue
|
||||
|
||||
# Find the corresponding image file (same stem, any non-.json extension).
|
||||
image_path = None
|
||||
for candidate in sidecar.parent.iterdir():
|
||||
if candidate.stem == sidecar.stem and candidate.suffix.lower() != '.json':
|
||||
image_path = candidate
|
||||
break
|
||||
if image_path is None:
|
||||
print(f'WARNING: no image file found for {sidecar.name}', file=sys.stderr)
|
||||
continue
|
||||
|
||||
# Parse the two candidate dates from the sidecar.
|
||||
dt_piwigo = _parse_datetime(data['date_created']) if data.get('date_created') else None
|
||||
dt_embedded = _parse_datetime(data['date_embedded']) if data.get('date_embedded') else None
|
||||
|
||||
# Determine which date to apply.
|
||||
if args.use == 'piwigo':
|
||||
chosen = dt_piwigo
|
||||
if chosen is None:
|
||||
print(f' NOTE: {image_path.name}: no piwigo date in sidecar; skipping.')
|
||||
noted += 1
|
||||
continue
|
||||
|
||||
elif args.use == 'embedded':
|
||||
chosen = dt_embedded
|
||||
if chosen is None:
|
||||
print(f' NOTE: {image_path.name}: no embedded date in sidecar; skipping.')
|
||||
noted += 1
|
||||
continue
|
||||
|
||||
else:
|
||||
# Interactive mode.
|
||||
if dt_piwigo is None and dt_embedded is None:
|
||||
print(f' NOTE: {image_path.name}: no dates available; skipping.')
|
||||
noted += 1
|
||||
continue
|
||||
|
||||
if dt_piwigo == dt_embedded or (dt_piwigo and dt_embedded is None):
|
||||
chosen = dt_piwigo
|
||||
elif dt_embedded and dt_piwigo is None:
|
||||
chosen = dt_embedded
|
||||
else:
|
||||
# Both present and different — ask the user.
|
||||
print(f'\n{image_path.name}')
|
||||
print(f' [1] piwigo : {dt_piwigo}')
|
||||
print(f' [2] embedded : {dt_embedded}')
|
||||
print(f' [s] skip')
|
||||
while True:
|
||||
choice = input('Choice [1/2/s]: ').strip().lower()
|
||||
if choice in ('s', 'skip', ''):
|
||||
chosen = None
|
||||
break
|
||||
if choice == '1':
|
||||
chosen = dt_piwigo
|
||||
break
|
||||
if choice == '2':
|
||||
chosen = dt_embedded
|
||||
break
|
||||
print(' Please enter 1, 2, or s.')
|
||||
|
||||
if chosen is None:
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
ts = chosen.timestamp()
|
||||
os.utime(image_path, (ts, ts))
|
||||
applied += 1
|
||||
|
||||
print(
|
||||
f'\nDone. {applied} mtime(s) set, {skipped} skipped, {noted} with no date.'
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description='Piwigo photo export and date-management utilities.',
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog=__doc__,
|
||||
)
|
||||
sub = parser.add_subparsers(dest='command', required=True)
|
||||
|
||||
# ------------------------------------------------------------------ export
|
||||
ep = sub.add_parser(
|
||||
'export',
|
||||
help='Export photos from Piwigo to a directory tree with JSON sidecars.',
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
|
||||
db = ep.add_argument_group('database')
|
||||
db.add_argument('--dbhost', metavar='HOST')
|
||||
db.add_argument('--dbuser', required=True, metavar='USER')
|
||||
db.add_argument('--dbpassword', metavar='PASS')
|
||||
db.add_argument('--dbname', required=True, metavar='NAME')
|
||||
db.add_argument(
|
||||
'--db-prefix', default='piwigo_', metavar='PREFIX',
|
||||
help='Piwigo table prefix (default: %(default)s)',
|
||||
)
|
||||
|
||||
io = ep.add_argument_group('paths')
|
||||
io.add_argument(
|
||||
'--src-path', required=True, metavar='DIR',
|
||||
help='Root of the Piwigo installation; piwigo_images.path is relative to this.',
|
||||
)
|
||||
io.add_argument(
|
||||
'--output-dir', required=True, metavar='DIR',
|
||||
help='Directory to write exported files into (created if absent).',
|
||||
)
|
||||
|
||||
behaviour = ep.add_argument_group('behaviour')
|
||||
behaviour.add_argument(
|
||||
'--metadata', choices=['exif', 'iptc', 'xmp'], nargs='+', metavar='FORMAT',
|
||||
help='Also embed metadata into the exported image copy using exiftool. '
|
||||
'One or more of: exif, iptc, xmp. '
|
||||
'Example: --metadata exif iptc xmp',
|
||||
)
|
||||
behaviour.add_argument(
|
||||
'--overwrite', action='store_true',
|
||||
help='Re-export image files that already exist in the output directory. '
|
||||
'JSON sidecars are always refreshed.',
|
||||
)
|
||||
behaviour.add_argument(
|
||||
'--no-overwrite-metadata', action='store_true',
|
||||
help='When embedding metadata, never overwrite a tag that already has a '
|
||||
'value in the file — skip it silently instead of prompting.',
|
||||
)
|
||||
|
||||
# --------------------------------------------------------------- set-dates
|
||||
dp = sub.add_parser(
|
||||
'set-dates',
|
||||
help='Set each exported image\'s mtime from dates recorded in its JSON sidecar.',
|
||||
)
|
||||
dp.add_argument(
|
||||
'--output-dir', required=True, metavar='DIR',
|
||||
help='Directory containing the exported files and JSON sidecars.',
|
||||
)
|
||||
dp.add_argument(
|
||||
'--use', choices=['piwigo', 'embedded'], metavar='SOURCE',
|
||||
help='Auto-select a date source (piwigo or embedded) instead of '
|
||||
'prompting for each image.',
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
if args.command == 'export':
|
||||
cmd_export(args)
|
||||
else:
|
||||
cmd_set_dates(args)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
|
||||
Reference in New Issue
Block a user