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:
464
src/core/__init__.py
Normal file
464
src/core/__init__.py
Normal 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()
|
||||
}
|
||||
Reference in New Issue
Block a user