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:
491
src/api/thetvdb_client.py
Normal file
491
src/api/thetvdb_client.py
Normal 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 []
|
||||
Reference in New Issue
Block a user