""" Cœur de l'application AnimeLibrarian - Projet de la Légion de Muyue """ import os from pathlib import Path from typing import Dict, Any, List, Optional, Tuple import logging from .directory_checker import DirectoryChecker from .file_scanner import FileScanner from .media_detector import MediaDetector from .file_renamer import FileRenamer from ..api.thetvdb_client import TheTVDBClient from ..api.tracemoe_client import TraceMoeClient from ..models.episode import Series, Episode logger = logging.getLogger(__name__) class AnimeLibrarianCore: """Cœur logique de l'application AnimeLibrarian""" def __init__(self, config: Dict[str, Any] = None): """ Initialise le cœur de l'application Args: config: Configuration de l'application """ self.config = config or {} # Initialisation des composants self.directory_checker = DirectoryChecker(config) self.file_scanner = FileScanner(config) self.media_detector = MediaDetector(config) self.file_renamer = FileRenamer(config) # Clients API self.tvdb_client = TheTVDBClient( api_key=self.config.get("thetvdb_api_key"), language=self.config.get("language", "fra") ) self.trace_moe_client = TraceMoeClient(self.config) # État de l'application self.current_directory = None self.series_list = [] self.selected_series = [] logger.info("Cœur d'AnimeLibrarian initialisé") def check_directory_compatibility(self, directory_path: str) -> Dict[str, Any]: """ Vérifie la compatibilité d'un répertoire Args: directory_path: Chemin du répertoire à vérifier Returns: Résultat détaillé de la vérification """ logger.info(f"Vérification de compatibilité: {directory_path}") result = self.directory_checker.check_directory(directory_path) if result.is_compatible: self.current_directory = Path(directory_path) logger.info(f"Répertoire compatible: {directory_path}") else: logger.warning(f"Répertoire incompatible: {directory_path}") return result.__dict__ def scan_series(self, directory_path: str = None) -> List[Dict[str, Any]]: """ Scan les séries dans le répertoire Args: directory_path: Chemin du répertoire (utilise le courant si None) Returns: Liste des séries trouvées """ if directory_path: self.current_directory = Path(directory_path) if not self.current_directory: raise ValueError("Aucun répertoire spécifié ou compatible") logger.info(f"Scan des séries dans: {self.current_directory}") # Scan des fichiers multimédia media_files = self.file_scanner.scan_directory( self.current_directory, {'.mp4', '.mkv', '.avi', '.mov', '.wmv', '.flv', '.webm', '.m4v', '.mpg', '.mpeg', '.3gp', '.ts', '.m2ts', '.ogv'} ) # Détection de la structure des séries series_structure = self._group_files_by_series(media_files) # Création des objets Series self.series_list = [] for series_name, files in series_structure.items(): series = self._create_series(series_name, files) self.series_list.append(series) logger.info(f"Trouvé {len(self.series_list)} séries") return [self._serialize_series(series) for series in self.series_list] def verify_episodes_numbers(self, series_indices: List[int]) -> Dict[str, Any]: """ Vérifie les numéros d'épisodes avec trace.moe Args: series_indices: Indices des séries à vérifier Returns: Résultats de la vérification """ logger.info(f"Vérification des numéros d'épisodes pour {len(series_indices)} séries") results = { "series_verified": 0, "episodes_verified": 0, "verification_results": [], "errors": [] } # Authentification TVDB si possible self.tvdb_client.login() for idx in series_indices: if 0 <= idx < len(self.series_list): series = self.series_list[idx] logger.info(f"Vérification de la série: {series.name}") try: series_result = self._verify_series_episodes(series) results["verification_results"].append(series_result) results["series_verified"] += 1 results["episodes_verified"] += len(series.episodes) except Exception as e: error_msg = f"Erreur vérification série {series.name}: {e}" logger.error(error_msg) results["errors"].append(error_msg) logger.info(f"Vérification terminée: {results['series_verified']} séries, {results['episodes_verified']} épisodes") return results def verify_files_integrity(self, series_indices: List[int]) -> Dict[str, Any]: """ Vérifie l'intégrité des fichiers Args: series_indices: Indices des séries à vérifier Returns: Résultats de la vérification d'intégrité """ logger.info(f"Vérification d'intégrité pour {len(series_indices)} séries") results = { "files_checked": 0, "valid_files": 0, "invalid_files": [], "issues": [], "duplicates": [] } # Collection de tous les fichiers all_files = [] for idx in series_indices: if 0 <= idx < len(self.series_list): series = self.series_list[idx] all_files.extend(series.episodes) # Vérification de chaque fichier for episode in all_files: metadata = self.media_detector.analyze_media_file(episode.file_path) results["files_checked"] += 1 if metadata.get("is_valid", False): results["valid_files"] += 1 # Mise à jour des métadonnées de l'épisode episode.duration = metadata.get("duration") episode.resolution = metadata.get("resolution") episode.codec = metadata.get("codec") episode.verified = True else: results["invalid_files"].append({ "filename": episode.filename, "path": str(episode.file_path), "issue": metadata.get("error", "Fichier invalide") }) results["issues"].append(f"Fichier invalide: {episode.filename}") # Détection des doublons file_info_list = [{"path": ep.file_path, "size": ep.file_size} for ep in all_files] duplicates = self.file_scanner.get_duplicates(file_info_list) if duplicates: results["duplicates"] = [ [str(dup["path"].name) for dup in dup_group] for dup_group in duplicates ] results["issues"].append(f"{len(duplicates)} groupes de doublons détectés") logger.info(f"Intégrité vérifiée: {results['valid_files']}/{results['files_checked']} fichiers valides") return results def rename_files(self, series_indices: List[int], dry_run: bool = False) -> Dict[str, Any]: """ Renomme les fichiers selon les normes TVDB Args: series_indices: Indices des séries à traiter dry_run: Si True, simule le renommage sans exécuter Returns: Résultats du renommage """ logger.info(f"Renommage pour {len(series_indices)} séries (dry_run={dry_run})") # Configuration du mode dry_run self.file_renamer.dry_run = dry_run results = { "series_processed": 0, "rename_plan": [], "rename_results": [], "stats": {} } # Authentification TVDB si possible self.tvdb_client.login() for idx in series_indices: if 0 <= idx < len(self.series_list): series = self.series_list[idx] logger.info(f"Préparation du renommage pour: {series.name}") # Préparation du plan de renommage rename_plan = self.file_renamer.prepare_rename_plan(series) results["rename_plan"].extend(rename_plan) # Exécution if rename_plan: rename_results = self.file_renamer.execute_rename_plan(rename_plan) results["rename_results"].extend(rename_results) results["series_processed"] += 1 # Statistiques results["stats"] = self.file_renamer.get_stats() logger.info(f"Renommage terminé: {results['stats']['renamed']} fichiers renommés") return results def _group_files_by_series(self, media_files: List[Dict[str, Any]]) -> Dict[str, List[Dict[str, Any]]]: """Groupe les fichiers par série""" import re series_structure = {} for file_info in media_files: filename = file_info['path'].name # Extraction du nom de série patterns = [ r'^\[?([^\[\]]+?)\]?\s*[-_.]?\s*S\d{1,2}E\d{1,2}', r'^([^-_\[\]]+?)(?:\s*[-_.]\s*)?(?:S\d{1,2}E\d{1,2}|Episode\s*\d{1,3}|E\d{1,3})', r'^([^-_()]+)(?:\s*[-_.]\s*)?\d{1,3}', ] series_name = filename # Fallback for pattern in patterns: match = re.search(pattern, filename, re.IGNORECASE) if match: series_name = match.group(1).strip() break # Nettoyage du nom series_name = re.sub(r'[^\w\s-]', '', series_name) series_name = re.sub(r'\s+', ' ', series_name).strip() if series_name not in series_structure: series_structure[series_name] = [] series_structure[series_name].append(file_info) return series_structure def _create_series(self, series_name: str, files: List[Dict[str, Any]]) -> Series: """Crée un objet Series à partir de fichiers""" series = Series( name=series_name, directory=self.current_directory / series_name if self.current_directory else Path("."), total_episodes=0 ) # Recherche TVDB tvdb_match = self.tvdb_client.search_best_match(series_name) if tvdb_match: series.tvdb_id = tvdb_match.get("id") series.total_episodes = tvdb_match.get("totalEpisodes") logger.debug(f"Série trouvée sur TVDB: {series_name} -> ID: {series.tvdb_id}") # Création des épisodes episodes = [] for file_info in files: # Analyse du nom de fichier episode_info = self.media_detector.extract_episode_info(file_info['path'].name) # Création de l'objet Episode episode = Episode( file_path=file_info['path'], series_name=series_name, season=episode_info['season'], episode=episode_info['episode'], title=episode_info['title'], special=episode_info['special'], file_size=file_info['size'], resolution=file_info.get('resolution'), codec=file_info.get('codec') ) # Analyse multimédia media_metadata = self.media_detector.analyze_media_file(file_info['path']) if media_metadata: episode.duration = media_metadata.get('duration') episode.resolution = media_metadata.get('resolution') or episode.resolution episode.codec = media_metadata.get('codec') or episode.codec episode.verified = media_metadata.get('is_valid', False) episodes.append(episode) # Tri des épisodes episodes.sort(key=lambda ep: (ep.season, ep.episode)) # Ajout à la série for episode in episodes: series.add_episode(episode) return series def _verify_series_episodes(self, series: Series) -> Dict[str, Any]: """Vérifie les épisodes d'une série avec trace.moe""" result = { "series_name": series.name, "episodes_verified": 0, "episode_results": [], "summary": {} } # Préparation des épisodes à vérifier episodes_to_verify = [] for episode in series.episodes: if not episode.special: # Ne vérifier que les épisodes normaux episodes_to_verify.append((episode.file_path, episode.episode, series.name)) # Vérification par lot if episodes_to_verify: verification_results = self.trace_moe_client.batch_verify_episodes(episodes_to_verify) for i, (episode, verification_result) in enumerate(zip(series.episodes, verification_results)): if not episode.special: # seulement les épisodes normaux episode_result = { "filename": episode.filename, "expected_episode": episode.episode, "identified_episode": verification_result.get("identified_episode"), "confidence": verification_result.get("confidence", 0), "match": verification_result.get("episode_match", False), "error": verification_result.get("error") } result["episode_results"].append(episode_result) result["episodes_verified"] += 1 # Résumé matches = sum(1 for r in result["episode_results"] if r.get("match", False)) result["summary"] = { "total": len(result["episode_results"]), "matches": matches, "mismatches": len(result["episode_results"]) - matches, "match_rate": matches / len(result["episode_results"]) if result["episode_results"] else 0 } return result def _serialize_series(self, series: Series) -> Dict[str, Any]: """Sérialise un objet Series pour l'UI""" completeness = series.check_completeness() return { "name": series.name, "directory": str(series.directory), "total_episodes": len(series.episodes), "regular_episodes": len(series.get_regular_episodes()), "special_episodes": len(series.get_specials()), "tvdb_id": series.tvdb_id, "completeness": completeness, "is_complete": completeness.get("is_complete", False), "missing_episodes": completeness.get("missing_episodes", []), "duplicate_episodes": completeness.get("duplicate_episodes", []), "total_size": sum(ep.file_size for ep in series.episodes) } def get_series_details(self, series_index: int) -> Optional[Dict[str, Any]]: """ Retourne les détails d'une série Args: series_index: Index de la série Returns: Détails de la série ou None """ if 0 <= series_index < len(self.series_list): series = self.series_list[series_index] return { "info": self._serialize_series(series), "episodes": [ { "filename": ep.filename, "season": ep.season, "episode": ep.episode, "title": ep.title, "special": ep.special, "duration": ep.duration, "resolution": ep.resolution, "codec": ep.codec, "file_size": ep.file_size, "verified": ep.verified, "absolute_number": ep.absolute_number } for ep in series.episodes ] } return None def get_application_status(self) -> Dict[str, Any]: """Retourne le statut actuel de l'application""" return { "current_directory": str(self.current_directory) if self.current_directory else None, "series_count": len(self.series_list), "total_episodes": sum(len(series.episodes) for series in self.series_list), "tvdb_configured": bool(self.config.get("thetvdb_api_key")), "trace_moe_configured": bool(self.config.get("trace_moe_api_key")), "tvdb_authenticated": self.tvdb_client.token is not None, "trace_moe_limits": self.trace_moe_client.get_api_limits() }