""" 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 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}") 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) 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())