Files
json-to-metadata/json_to_metadata/cli.py

382 lines
11 KiB
Python

"""
Interface en ligne de commande pour json-to-metadata.
Ce module fournit le point d'entrée CLI pour le script de gestion
des métadonnées EXIF.
"""
import argparse
import logging
import os
import sys
from datetime import datetime
from pathlib import Path
from . import __version__
from .exif_writer import (
ExiftoolError,
ExiftoolNotFoundError,
check_exiftool_available,
write_exif_with_fallback,
)
from .json_parser import ValidationError, load_json, validate_roll
from .metadata_mapper import map_frame_to_exif, parse_date
from .xmp_writer import write_xmp_sidecar
# Configuration du logging
logger = logging.getLogger('json_to_metadata')
def setup_logging(verbose: bool = False) -> None:
"""
Configure le système de logging.
Args:
verbose: Si True, active le niveau DEBUG.
"""
level = logging.DEBUG if verbose else logging.INFO
format_str = '%(levelname)s: %(message)s'
if verbose:
format_str = '%(asctime)s - %(name)s - %(levelname)s - %(message)s'
logging.basicConfig(
level=level,
format=format_str,
handlers=[logging.StreamHandler(sys.stderr)]
)
def set_file_mtime(filepath: Path, dt: datetime, dry_run: bool = False) -> bool:
"""
Modifie la date de modification (mtime) d'un fichier.
Args:
filepath: Chemin vers le fichier.
dt: Date/heure à appliquer.
dry_run: Si True, affiche sans modifier.
Returns:
True si la modification a réussi.
"""
try:
timestamp = dt.timestamp()
if dry_run:
logger.info(f"[DRY-RUN] Modification date fichier : {filepath.name}{dt}")
return True
# Modifie atime et mtime
os.utime(filepath, (timestamp, timestamp))
logger.debug(f"Date fichier modifiée : {filepath.name}{dt}")
return True
except (OSError, ValueError) as e:
logger.warning(f"Impossible de modifier la date du fichier {filepath.name} : {e}")
return False
def find_image_for_frame(
frame: dict,
directory: Path,
roll_id: str | int
) -> Path | None:
"""
Recherche le fichier image correspondant à un frame.
Essaie plusieurs conventions de nommage courantes.
Args:
frame: Dictionnaire du frame.
directory: Répertoire contenant les images.
roll_id: Identifiant du roll.
Returns:
Chemin vers l'image trouvée, ou None si non trouvée.
"""
frame_id = frame.get('id', '')
# Extensions d'images supportées
extensions = ['.tif', '.tiff', '.jpg', '.jpeg', '.avif', '.heic', '.webp', '.png']
# Patterns de noms à essayer
patterns = [
f"{frame_id}", # 1, 2, 3...
f"{frame_id:02d}" if isinstance(frame_id, int) else frame_id, # 01, 02...
f"{frame_id:03d}" if isinstance(frame_id, int) else frame_id, # 001, 002...
f"frame_{frame_id}", # frame_1, frame_2...
f"frame{frame_id}", # frame1, frame2...
f"{roll_id}_{frame_id}", # roll_frame
f"img_{frame_id}", # img_1, img_2...
f"IMG_{frame_id}", # IMG_1, IMG_2...
]
for pattern in patterns:
for ext in extensions:
# Essayer en minuscule et majuscule
for name in [f"{pattern}{ext}", f"{pattern}{ext.upper()}"]:
filepath = directory / name
if filepath.exists():
return filepath
return None
def process_roll(
roll: dict,
image_dir: Path,
dry_run: bool = False,
force_xmp: bool = False,
set_file_date: bool = False
) -> tuple[int, int, int]:
"""
Traite un roll complet : valide et écrit les métadonnées pour chaque frame.
Args:
roll: Dictionnaire du roll validé.
image_dir: Répertoire contenant les images.
dry_run: Mode simulation.
force_xmp: Force la création de fichiers XMP.
set_file_date: Modifie la date de modification du fichier.
Returns:
Tuple (succès, échecs, images non trouvées).
"""
success_count = 0
failure_count = 0
not_found_count = 0
roll_id = roll.get('id', 'unknown')
frames = roll.get('frames', [])
logger.info(f"Traitement du roll '{roll_id}' : {len(frames)} frames")
for frame in frames:
frame_id = frame.get('id', '?')
# Rechercher l'image
image_path = find_image_for_frame(frame, image_dir, roll_id)
if image_path is None:
logger.warning(f"Image non trouvée pour le frame {frame_id}")
not_found_count += 1
continue
logger.debug(f"Image trouvée : {image_path.name}")
# Mapper les métadonnées
try:
tags = map_frame_to_exif(frame, roll)
except Exception as e:
logger.error(f"Erreur de mapping pour le frame {frame_id} : {e}")
failure_count += 1
continue
if not tags:
logger.warning(f"Aucune métadonnée à écrire pour le frame {frame_id}")
continue
# Écrire les métadonnées
write_success = False
xmp_path = None
try:
if force_xmp:
xmp_path = write_xmp_sidecar(image_path, tags, dry_run)
if xmp_path:
write_success = True
success_count += 1
logger.info(f"Frame {frame_id} : XMP créé ({image_path.name})")
else:
success, method = write_exif_with_fallback(
image_path,
tags,
lambda p, t, d: write_xmp_sidecar(p, t, d),
dry_run
)
if success:
write_success = True
success_count += 1
logger.info(f"Frame {frame_id} : métadonnées écrites via {method}")
# Si XMP créé, récupérer le chemin
if method == 'xmp':
xmp_path = image_path.with_suffix('.xmp')
else:
failure_count += 1
logger.error(f"Frame {frame_id} : échec de l'écriture")
except ExiftoolError as e:
logger.error(f"Erreur exiftool pour le frame {frame_id} : {e}")
failure_count += 1
except IOError as e:
logger.error(f"Erreur I/O pour le frame {frame_id} : {e}")
failure_count += 1
# Modifier la date du fichier si demandé
if write_success and set_file_date and frame.get('date'):
try:
frame_date = parse_date(frame['date'])
set_file_mtime(image_path, frame_date, dry_run)
# Modifier aussi le fichier XMP sidecar s'il existe
if xmp_path and xmp_path.exists():
set_file_mtime(xmp_path, frame_date, dry_run)
except ValueError as e:
logger.warning(f"Date invalide pour le frame {frame_id} : {e}")
return success_count, failure_count, not_found_count
def main(args: list[str] | None = None) -> int:
"""
Point d'entrée principal du CLI.
Args:
args: Arguments de ligne de commande (utilise sys.argv si None).
Returns:
Code de retour (0 = succès, 1 = erreur).
"""
parser = argparse.ArgumentParser(
prog='json-to-metadata',
description='Écrit les métadonnées EXIF depuis un fichier JSON Exif Notes.',
epilog='Exemple : python -m json_to_metadata roll.json -d ./images'
)
parser.add_argument(
'json_file',
type=Path,
help='Chemin vers le fichier JSON Exif Notes'
)
parser.add_argument(
'-d', '--directory',
type=Path,
default=None,
help='Répertoire contenant les images (défaut : même répertoire que le JSON)'
)
parser.add_argument(
'-v', '--verbose',
action='store_true',
help='Mode verbeux (niveau DEBUG)'
)
parser.add_argument(
'--dry-run',
action='store_true',
help='Affiche les actions sans les exécuter'
)
parser.add_argument(
'--force-xmp',
action='store_true',
help='Force la création de fichiers XMP même pour les formats supportant EXIF'
)
parser.add_argument(
'--set-file-date',
action='store_true',
help='Modifie la date de modification des fichiers selon la date de prise de vue'
)
parser.add_argument(
'--version',
action='version',
version=f'%(prog)s {__version__}'
)
parsed_args = parser.parse_args(args)
# Configuration du logging
setup_logging(parsed_args.verbose)
# Vérification de exiftool (sauf si force-xmp)
if not parsed_args.force_xmp:
try:
check_exiftool_available()
except ExiftoolNotFoundError as e:
logger.error(str(e))
return 1
# Chargement et validation du JSON
try:
data = load_json(parsed_args.json_file)
except FileNotFoundError as e:
logger.error(str(e))
return 1
except Exception as e:
logger.error(f"Erreur lors du chargement du JSON : {e}")
return 1
# Le JSON peut contenir un roll unique ou une liste de rolls
if isinstance(data, list):
rolls = data
elif isinstance(data, dict):
# Si c'est un objet avec une clé 'rolls', l'utiliser
if 'rolls' in data:
rolls = data['rolls']
else:
# Sinon, c'est un roll unique
rolls = [data]
else:
logger.error("Format JSON non reconnu : attendu un objet ou une liste")
return 1
# Validation des rolls
valid_rolls = []
for i, roll in enumerate(rolls):
try:
validate_roll(roll)
valid_rolls.append(roll)
except ValidationError as e:
logger.error(f"Roll {i + 1} invalide : {e}")
if not valid_rolls:
logger.error("Aucun roll valide trouvé dans le fichier JSON")
return 1
# Déterminer le répertoire des images
if parsed_args.directory:
image_dir = parsed_args.directory
else:
image_dir = parsed_args.json_file.parent
if not image_dir.is_dir():
logger.error(f"Répertoire d'images invalide : {image_dir}")
return 1
logger.info(f"Répertoire des images : {image_dir}")
if parsed_args.dry_run:
logger.info("=== MODE DRY-RUN : aucune modification ne sera effectuée ===")
# Traitement de chaque roll
total_success = 0
total_failure = 0
total_not_found = 0
for roll in valid_rolls:
success, failure, not_found = process_roll(
roll,
image_dir,
dry_run=parsed_args.dry_run,
force_xmp=parsed_args.force_xmp,
set_file_date=parsed_args.set_file_date
)
total_success += success
total_failure += failure
total_not_found += not_found
# Résumé
logger.info("=" * 50)
logger.info(f"Résumé : {total_success} succès, {total_failure} échecs, "
f"{total_not_found} images non trouvées")
if total_failure > 0:
return 1
return 0
if __name__ == '__main__':
sys.exit(main())