From a6db47237e752a8ad9752b186cc149c49b2a66d9 Mon Sep 17 00:00:00 2001 From: Timothy Allen Date: Sun, 12 Apr 2026 08:11:16 +0200 Subject: [PATCH] Set file modification time for exporting images --- piwigo_export.py | 268 ++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 221 insertions(+), 47 deletions(-) diff --git a/piwigo_export.py b/piwigo_export.py index 30e33e9..9d33230 100644 --- a/piwigo_export.py +++ b/piwigo_export.py @@ -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()