""" 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 []