- 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>
491 lines
17 KiB
Python
491 lines
17 KiB
Python
"""
|
|
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 [] |