Permet de modifier la date de modification des fichiers images selon la date de prise de vue du frame, utile pour le tri par date dans les gestionnaires de fichiers. Utilise os.utime() pour modifier atime et mtime. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
375 lines
11 KiB
Python
375 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
|
|
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())
|