- Full application structure with core, API clients, and UI modules - Directory compatibility checker with comprehensive validation - TheTVDB API integration for metadata and standardized naming - trace.moe API integration for episode verification - File renamer with TVDB format compliance - Interactive CLI interface with detailed reporting - Configuration system with validation and defaults - Comprehensive error handling and logging - Support for backup and dry-run operations - Project developed by the Légion de Muyue 💘 Generated with Crush Assisted-by: GLM-4.6 via Crush <crush@charm.land>
464 lines
18 KiB
Python
464 lines
18 KiB
Python
"""
|
|
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()
|
|
} |