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 argparse
|
||||||
|
import datetime
|
||||||
import json
|
import json
|
||||||
import math
|
import math
|
||||||
import os
|
import os
|
||||||
@@ -198,6 +199,46 @@ def _xmp_datetime(s) -> str:
|
|||||||
return f'{s[:10]}T{t}'
|
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:
|
def _build_metadata_tags(metadata: dict, fmt: str) -> dict:
|
||||||
"""
|
"""
|
||||||
Build a dict of { 'GROUP:TagName': value } for everything we want to write.
|
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, [])
|
tags = tags_by_image.get(image_id, [])
|
||||||
cat_ids = cats_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 = {
|
metadata = {
|
||||||
'title': image_row.get('name'),
|
'title': image_row.get('name'),
|
||||||
'author': image_row.get('author'),
|
'author': image_row.get('author'),
|
||||||
'date_created': str(image_row['date_creation']) if image_row.get('date_creation') else None,
|
'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_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'),
|
'description': image_row.get('comment'),
|
||||||
'tags': tags,
|
'tags': tags,
|
||||||
'albums': [category_display_path(cid, categories) for cid in cat_ids],
|
'albums': [category_display_path(cid, categories) for cid in cat_ids],
|
||||||
@@ -486,6 +531,12 @@ def export_image(
|
|||||||
'original_path': image_row['path'],
|
'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 = (
|
dest_dirs = (
|
||||||
[output_dir / category_fs_path(cid, categories) for cid in cat_ids]
|
[output_dir / category_fs_path(cid, categories) for cid in cat_ids]
|
||||||
if cat_ids
|
if cat_ids
|
||||||
@@ -539,53 +590,8 @@ def export_image(
|
|||||||
# Entry point
|
# Entry point
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def main():
|
def cmd_export(args):
|
||||||
parser = argparse.ArgumentParser(
|
"""Run the export subcommand (database → filesystem)."""
|
||||||
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()
|
|
||||||
|
|
||||||
if args.metadata:
|
if args.metadata:
|
||||||
check_exiftool()
|
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__':
|
if __name__ == '__main__':
|
||||||
main()
|
main()
|
||||||
|
|||||||
Reference in New Issue
Block a user