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

491
src/api/thetvdb_client.py Normal file
View File

@@ -0,0 +1,491 @@
"""
Client API pour TheTVDB - Projet de la Légion de Muyue
"""
import json
import time
import requests
from typing import Dict, Any, List, Optional, Tuple
from datetime import datetime, timedelta
import logging
logger = logging.getLogger(__name__)
class TheTVDBClient:
"""Client pour l'API TheTVDB"""
BASE_URL = "https://api.thetvdb.com"
def __init__(self, api_key: str = None, language: str = "fra"):
"""
Initialise le client TheTVDB
Args:
api_key: Clé API TheTVDB (requise pour les requêtes authentifiées)
language: Code de langue pour les métadonnées ('fra', 'eng', etc.)
"""
self.api_key = api_key
self.language = language
self.token = None
self.token_expires = None
self.session = requests.Session()
# Headers par défaut
self.session.headers.update({
"Content-Type": "application/json",
"Accept": "application/json"
})
def login(self) -> bool:
"""
Authentifie le client auprès de l'API TheTVDB
Returns:
bool: True si l'authentification a réussi
"""
if not self.api_key:
logger.warning("Aucune clé API TheTVDB fournie - utilisation en mode limité")
return False
try:
auth_data = {
"apikey": self.api_key
}
response = self.session.post(
f"{self.BASE_URL}/login",
json=auth_data,
timeout=10
)
if response.status_code == 200:
auth_response = response.json()
self.token = auth_response.get("token")
if self.token:
# Ajout du token aux headers
self.session.headers.update({
"Authorization": f"Bearer {self.token}"
})
# Le token expire après 24 heures
self.token_expires = datetime.now() + timedelta(hours=24)
logger.info("Authentification TheTVDB réussie")
return True
else:
logger.error("Token non trouvé dans la réponse TheTVDB")
return False
else:
logger.error(f"Échec de l'authentification TheTVDB: {response.status_code} - {response.text}")
return False
except Exception as e:
logger.error(f"Erreur lors de l'authentification TheTVDB: {e}")
return False
def ensure_authenticated(self) -> bool:
"""Vérifie que le client est authentifié et ré-authentifie si nécessaire"""
# Si pas de clé API, on ne peut pas s'authentifier
if not self.api_key:
return False
# Si on a un token valide
if self.token and self.token_expires and datetime.now() < self.token_expires:
return True
# Sinon, on s'authentifie
return self.login()
def search_series(self, name: str) -> List[Dict[str, Any]]:
"""
Recherche des séries par nom
Args:
name: Nom de la série à rechercher
Returns:
Liste des séries trouvées
"""
try:
# Ajout de paramètres pour les résultats pertinents
params = {
"name": name,
"type": "series"
}
# Utilisation de l'endpoint public pour la recherche
url = f"{self.BASE_URL}/search/series"
response = self.session.get(url, params=params, timeout=10)
if response.status_code == 200:
data = response.json()
return data.get("data", [])
elif response.status_code == 401 and self.ensure_authenticated():
# Réessayer avec authentification
response = self.session.get(url, params=params, timeout=10)
if response.status_code == 200:
data = response.json()
return data.get("data", [])
logger.error(f"Erreur recherche série {name}: {response.status_code} - {response.text}")
return []
except Exception as e:
logger.error(f"Erreur lors de la recherche de série {name}: {e}")
return []
def get_series_by_id(self, series_id: int) -> Optional[Dict[str, Any]]:
"""
Récupère les informations détaillées d'une série par son ID
Args:
series_id: ID TheTVDB de la série
Returns:
Dictionnaire avec les informations de la série ou None
"""
try:
url = f"{self.BASE_URL}/series/{series_id}"
params = {"lang": self.language}
response = self.session.get(url, params=params, timeout=10)
if response.status_code == 200:
return response.json().get("data")
elif response.status_code == 401 and self.ensure_authenticated():
response = self.session.get(url, params=params, timeout=10)
if response.status_code == 200:
return response.json().get("data")
logger.error(f"Erreur récupération série {series_id}: {response.status_code}")
return None
except Exception as e:
logger.error(f"Erreur lors de la récupération de la série {series_id}: {e}")
return None
def get_episodes(self, series_id: int, page: int = 0) -> List[Dict[str, Any]]:
"""
Récupère les épisodes d'une série
Args:
series_id: ID TheTVDB de la série
page: Page de résultats (pagination)
Returns:
Liste des épisodes
"""
try:
url = f"{self.BASE_URL}/series/{series_id}/episodes/query"
params = {
"lang": self.language,
"page": page,
"pageSize": 100 # Maximum par page
}
response = self.session.get(url, params=params, timeout=10)
if response.status_code == 200:
data = response.json()
return data.get("data", [])
elif response.status_code == 401 and self.ensure_authenticated():
response = self.session.get(url, params=params, timeout=10)
if response.status_code == 200:
data = response.json()
return data.get("data", [])
logger.error(f"Erreur récupération épisodes série {series_id}: {response.status_code}")
return []
except Exception as e:
logger.error(f"Erreur lors de la récupération des épisodes de la série {series_id}: {e}")
return []
def get_all_episodes(self, series_id: int) -> List[Dict[str, Any]]:
"""
Récupère tous les épisodes d'une série (toutes les pages)
Args:
series_id: ID TheTVDB de la série
Returns:
Liste complète de tous les épisodes
"""
all_episodes = []
page = 0
while True:
episodes = self.get_episodes(series_id, page)
if not episodes:
break
all_episodes.extend(episodes)
page += 1
# Petite pause pour éviter de surcharger l'API
time.sleep(0.1)
logger.info(f"Récupéré {len(all_episodes)} épisodes pour la série {series_id}")
return all_episodes
def get_episode_by_number(self, series_id: int, season: int, episode: int) -> Optional[Dict[str, Any]]:
"""
Récupère un épisode spécifique par son numéro
Args:
series_id: ID TheTVDB de la série
season: Numéro de saison
episode: Numéro d'épisode
Returns:
Dictionnaire avec les informations de l'épisode ou None
"""
try:
url = f"{self.BASE_URL}/series/{series_id}/episodes/{season}/{episode}"
params = {"lang": self.language}
response = self.session.get(url, params=params, timeout=10)
if response.status_code == 200:
return response.json().get("data")
elif response.status_code == 401 and self.ensure_authenticated():
response = self.session.get(url, params=params, timeout=10)
if response.status_code == 200:
return response.json().get("data")
return None
except Exception as e:
logger.error(f"Erreur lors de la récupération de l'épisode S{season:02d}E{episode:02d}: {e}")
return None
def get_series_artwork(self, series_id: int) -> List[Dict[str, Any]]:
"""
Récupère les artworks (posters, bannières) d'une série
Args:
series_id: ID TheTVDB de la série
Returns:
Liste des artworks
"""
try:
url = f"{self.BASE_URL}/series/{series_id}/images/query"
params = {
"keyType": "poster,series",
"lang": self.language
}
response = self.session.get(url, params=params, timeout=10)
if response.status_code == 200:
data = response.json()
return data.get("data", [])
elif response.status_code == 401 and self.ensure_authenticated():
response = self.session.get(url, params=params, timeout=10)
if response.status_code == 200:
data = response.json()
return data.get("data", [])
return []
except Exception as e:
logger.error(f"Erreur lors de la récupération des artworks de la série {series_id}: {e}")
return []
def build_episode_map(self, series_id: int) -> Dict[Tuple[int, int], Dict[str, Any]]:
"""
Construit une map des épisodes pour lookup rapide
Returns:
Dict avec (saison, épisode) -> données épisode
"""
episodes = self.get_all_episodes(series_id)
episode_map = {}
for ep in episodes:
season = ep.get("airedSeason", 1)
episode_num = ep.get("airedEpisodeNumber", 0)
if season and episode_num:
episode_map[(season, episode_num)] = ep
logger.info(f"Map d'épisodes construite: {len(episode_map)} entrées")
return episode_map
def get_episode_title(self, series_id: int, season: int, episode: int, fallback_title: str = None) -> str:
"""
Récupère le titre d'un épisode
Args:
series_id: ID TheTVDB de la série
season: Numéro de saison
episode: Numéro d'épisode
fallback_title: Titre par défaut si non trouvé
Returns:
Titre de l'épisode
"""
episode_data = self.get_episode_by_number(series_id, season, episode)
if episode_data and episode_data.get("episodeName"):
return episode_data["episodeName"]
# Utiliser le titre de fallback ou générer un titre générique
if fallback_title:
return fallback_title
return f"Episode {episode:02d}"
def search_best_match(self, search_name: str, year: int = None) -> Optional[Dict[str, Any]]:
"""
Recherche la meilleure correspondance pour une série
Args:
search_name: Nom à rechercher
year: Année de sortie pour affiner la recherche
Returns:
Meilleure correspondance ou None
"""
search_term = search_name
# Ajouter l'année si disponible
if year:
search_term = f"{search_term} ({year})"
results = self.search_series(search_term)
if not results and year:
# Réessayer sans l'année
results = self.search_series(search_name)
if not results:
return None
# Score de correspondance
best_match = None
best_score = 0
search_lower = search_name.lower()
for series in results:
name = series.get("seriesName", "").lower()
# Score basique de similarité
score = 0
# Correspondance exacte
if name == search_lower:
score = 100
# Correspondance partielle
elif search_lower in name or name in search_lower:
score = 70
# Correspondance de mots
else:
search_words = set(search_lower.split())
name_words = set(name.split())
common_words = search_words.intersection(name_words)
score = len(common_words) * 20
# Bonus pour l'année
if year:
series_year = self._extract_year_from_series(series)
if series_year == year:
score += 15
elif abs(series_year - year) <= 1:
score += 5
# Mise à jour de la meilleure correspondance
if score > best_score:
best_score = score
best_match = series
# Seuil minimum de confiance
if best_score >= 50:
logger.info(f"Meilleure correspondance pour '{search_name}': {best_match.get('seriesName')} (score: {best_score})")
return best_match
logger.warning(f"Pas de correspondance suffisante pour '{search_name}' (meilleur score: {best_score})")
return None
def _extract_year_from_series(self, series: Dict[str, Any]) -> int:
"""Extrait l'année de la série"""
first_aired = series.get("firstAired", "")
if first_aired:
try:
return datetime.strptime(first_aired, "%Y-%m-%d").year
except:
pass
return 0
def get_recommended_format(self, series: Dict[str, Any], episode_info: Dict[str, Any]) -> str:
"""
Génère le nom de fichier recommandé selon le format TheTVDB
Args:
series: Informations de la série
episode_info: Informations de l'épisode
Returns:
Nom de fichier formaté
"""
series_name = series.get("seriesName", "Unknown Series")
season = episode_info.get("season", 1)
episode = episode_info.get("episode", 0)
title = episode_info.get("title", "")
# Nettoyage et formatage
clean_series = self._clean_string(series_name)
clean_title = self._clean_string(title) if title else ""
# Construction du nom
if episode_info.get("special", False):
# Épisode spécial
formatted = f"{clean_series} - S00E{episode:02d}"
else:
# Épisode normal
formatted = f"{clean_series} - S{season:02d}E{episode:02d}"
# Ajout du titre
if clean_title:
formatted += f" - {clean_title}"
return formatted
def _clean_string(self, text: str) -> str:
"""Nettoie une chaîne pour le formatage de nom de fichier"""
if not text:
return ""
# Suppression des caractères invalides
invalid_chars = '<>:"/\\|?*'
for char in invalid_chars:
text = text.replace(char, '')
# Normalisation des espaces
text = text.replace(' ', ' ').strip()
return text
def get_languages(self) -> List[Dict[str, Any]]:
"""Récupère la liste des langues disponibles"""
try:
url = f"{self.BASE_URL}/languages"
response = self.session.get(url, timeout=10)
if response.status_code == 200:
return response.json().get("data", [])
return []
except Exception as e:
logger.error(f"Erreur lors de la récupération des langues: {e}")
return []