Set file modification time for exporting images

This commit is contained in:
2026-04-12 08:11:16 +02:00
parent 6ef74e06b3
commit a6db47237e
+221 -47
View File
@@ -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()