Add complete AnimeLibrarian implementation

- 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>
This commit is contained in:
2025-12-21 18:24:06 +01:00
parent 1ba3055895
commit 2605f08feb
17 changed files with 3828 additions and 0 deletions

464
src/core/__init__.py Normal file
View File

@@ -0,0 +1,464 @@
"""
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()
}