import base64 import json import re import time import uuid import subprocess import tempfile import shutil from typing import Generator, Optional, Union, List, Any from pathlib import Path from functools import partial import click from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import padding from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes from langcodes import Language from unshackle.core import binaries from unshackle.core.config import config from unshackle.core.manifests import HLS from unshackle.core.search_result import SearchResult from unshackle.core.service import Service from unshackle.core.session import session from unshackle.core.titles import Episode, Series from unshackle.core.tracks import Tracks, Chapters, Chapter from unshackle.core.tracks.audio import Audio from unshackle.core.tracks.video import Video from unshackle.core.tracks.audio import Audio from unshackle.core.tracks.subtitle import Subtitle class VideoNoAudio(Video): """ Video track qui enlève automatiquement l'audio après téléchargement. Nécessaire car ADN fournit des streams HLS avec audio muxé. """ def download(self, session, prepare_drm, max_workers=None, progress=None, *, cdm=None): """Override : télécharge puis demuxe pour enlever l'audio.""" import logging log = logging.getLogger('ADN.VideoNoAudio') # Téléchargement normal super().download(session, prepare_drm, max_workers, progress, cdm=cdm) # Si pas de path, échec du téléchargement if not self.path or not self.path.exists(): return # Vérifier FFmpeg disponible if not binaries.FFMPEG: log.warning("FFmpeg not found, cannot remove audio from video") return # Demuxer : enlever l'audio if progress: progress(downloaded="Removing audio") original_path = self.path noaudio_path = original_path.with_stem(f"{original_path.stem}_noaudio") try: log.debug(f"Removing audio from {original_path.name}") result = subprocess.run( [ binaries.FFMPEG, '-i', str(original_path), '-vcodec', 'copy', # Copie vidéo sans réencodage '-an', # Enlève l'audio '-y', str(noaudio_path) ], capture_output=True, text=True, timeout=120 ) if result.returncode != 0: log.error(f"FFmpeg demux failed: {result.stderr}") noaudio_path.unlink(missing_ok=True) return if not noaudio_path.exists() or noaudio_path.stat().st_size < 1000: log.error("Demuxed video is empty or too small") noaudio_path.unlink(missing_ok=True) return # Remplacer le fichier original log.debug(f"Video demuxed successfully: {noaudio_path.stat().st_size} bytes") original_path.unlink() noaudio_path.rename(original_path) if progress: progress(downloaded="Downloaded") except subprocess.TimeoutExpired: log.error("FFmpeg demux timeout") noaudio_path.unlink(missing_ok=True) except Exception as e: log.error(f"Failed to demux video: {e}") noaudio_path.unlink(missing_ok=True) class AudioExtracted(Audio): """ Audio track déjà extrait d'un flux HLS muxé. Override download() pour copier le fichier au lieu de télécharger. """ def __init__(self, *args, extracted_path: Path, **kwargs): # URL vide pour éviter que curl essaie de télécharger super().__init__(*args, url="", **kwargs) self.extracted_path = extracted_path def download(self, session, prepare_drm, max_workers=None, progress=None, *, cdm=None): """Override : copie le fichier extrait au lieu de télécharger.""" if not self.extracted_path or not self.extracted_path.exists(): if progress: progress(downloaded="[red]FAILED") raise ValueError(f"Extracted audio file not found: {self.extracted_path}") # Créer le path de destination (même logique que Track.download) track_type = self.__class__.__name__ save_path = config.directories.temp / f"{track_type}_{self.id}.m4a" if progress: progress(downloaded="Copying", total=100, completed=0) # Copier le fichier extrait vers le path final config.directories.temp.mkdir(parents=True, exist_ok=True) shutil.copy2(self.extracted_path, save_path) self.path = save_path if progress: progress(downloaded="Downloaded", completed=100) class SubtitleEmbedded(Subtitle): """ Subtitle avec contenu embarqué (data URI). Override download() pour écrire le contenu directement. """ def __init__(self, *args, embedded_content: str, **kwargs): # URL vide pour éviter que curl essaie de télécharger super().__init__(*args, url="", **kwargs) self.embedded_content = embedded_content def download(self, session, prepare_drm, max_workers=None, progress=None, *, cdm=None): """Override : écrit le contenu embarqué au lieu de télécharger.""" if not self.embedded_content: if progress: progress(downloaded="[red]FAILED") raise ValueError("No embedded content in subtitle") # Créer le path de destination track_type = "Subtitle" save_path = config.directories.temp / f"{track_type}_{self.id}.{self.codec.extension}" if progress: progress(downloaded="Writing", total=100, completed=0) # Écrire le contenu config.directories.temp.mkdir(parents=True, exist_ok=True) save_path.write_text(self.embedded_content, encoding='utf-8') self.path = save_path if progress: progress(downloaded="Downloaded", completed=100) class ADN(Service): """ Service code for Animation Digital Network (ADN). \b Version: 3.2.1 (FINAL - Full multi-audio/subtitle support with custom Track classes) Authorization: Credentials Robustness: Video: Clear HLS (Highest Quality) Audio: Pre-extracted from muxed streams with AudioExtracted class Subs: AES-128 Encrypted JSON -> ASS format with SubtitleEmbedded class Technical Solution: - ADN provides HLS streams with muxed video+audio (not separable) - AudioExtracted: Extracts audio in get_tracks(), copies during download() - SubtitleEmbedded: Decrypts and converts to ASS, writes during download() - Result: MKV with 1 video + multiple audio tracks + subtitles Custom Track Classes: - AudioExtracted: Bypasses curl file:// limitation with direct file copy - SubtitleEmbedded: Bypasses requests data: limitation with direct write Made by: guilara_tv """ RSA_PUBLIC_KEY = """-----BEGIN PUBLIC KEY----- MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCbQrCJBRmaXM4gJidDmcpWDssg numHinCLHAgS4buMtdH7dEGGEUfBofLzoEdt1jqcrCDT6YNhM0aFCqbLOPFtx9cg /X2G/G5bPVu8cuFM0L+ehp8s6izK1kjx3OOPH/kWzvstM5tkqgJkNyNEvHdeJl6 KhS+IFEqwvZqgbBpKuwIDAQAB -----END PUBLIC KEY-----""" TITLE_RE = r"^(?:https?://(?:www\.)?animationdigitalnetwork\.com/video/[^/]+/)?(?P\d+)" @staticmethod def get_session(): return session("okhttp4") @staticmethod @click.command( name="ADN", short_help="Téléchargement depuis Animation Digital Network", help=( "Télécharge des séries ou films depuis ADN.\n\n" "TITLE : L'URL de la série ou son ID (ex: 1125).\n\n" "SYSTÈME DE SÉLECTION :\n" " - Simple : '-e 1-5' (épisodes 1 à 5)\n" " - Saisons : '-e S2' ou '-e S02' (toute la saison 2) ou '-e S2E1-12'\n" " - Mixte : '-e 1,3,S2E5' ou '-e 1,3,S02E05'\n" " - Bonus : '-e NC1,OAV1'" ) ) @click.argument("title", type=str, required=True) @click.option( "-e", "--episode", "select", type=str, help="Sélection : numéros, plages (5-10), saisons (S1, S2) ou combiné (S1E5)." ) @click.option( "--but", is_flag=True, help="Inverse la sélection : télécharge tout SAUF les épisodes spécifiés avec -e." ) @click.option( "--all", "all_eps", is_flag=True, help="Ignore toutes les restrictions et télécharge l'intégralité de la série." ) @click.pass_context def cli(ctx, **kwargs) -> "ADN": return ADN(ctx, **kwargs) def __init__(self, ctx, title: str, select: Optional[str] = None, but: bool = False, all_eps: bool = False): self.title = title self.select_str = select self.but = but self.all_eps = all_eps self.access_token: Optional[str] = None self.refresh_token: Optional[str] = None self.token_expiration: Optional[int] = None super().__init__(ctx) self.locale = self.config.get("params", {}).get("locale", "fr") self.session.headers.update(self.config.get("headers", {})) self.session.headers["x-target-distribution"] = self.locale @staticmethod def _timecode_to_ms(tc: str) -> int: """Convert HH:MM:SS timecode to milliseconds.""" parts = tc.split(':') hours = int(parts[0]) minutes = int(parts[1]) seconds = int(parts[2]) return (hours * 3600 + minutes * 60 + seconds) * 1000 @property def auth_header(self) -> dict: return { "Authorization": f"Bearer {self.access_token}", "X-Access-Token": self.access_token } def ensure_authenticated(self) -> None: """Vérifie le token et rafraîchit si nécessaire.""" current_time = int(time.time()) if self.access_token and self.token_expiration and current_time < (self.token_expiration - 60): return cache_key = f"adn_auth_{self.credential.sha1 if self.credential else 'default'}" cached = self.cache.get(cache_key) if cached and not cached.expired: self.access_token = cached.data["access_token"] self.refresh_token = cached.data["refresh_token"] self.token_expiration = cached.data["token_expiration"] self.session.headers.update(self.auth_header) self.log.debug("Loaded authentication from cache") else: self.authenticate(credential=self.credential) def authenticate(self, cookies=None, credential=None) -> None: super().authenticate(cookies, credential) if self.refresh_token: try: self._do_refresh() return except Exception: self.log.warning("Refresh failed, proceeding to full login") if not credential: raise ValueError("Credentials required for ADN") response = self.session.post( url=self.config["endpoints"]["login"], json={ "username": credential.username, "password": credential.password, "source": "Web" } ) if response.status_code != 200: self.log.error(f"Login failed: {response.status_code} - {response.text}") response.raise_for_status() self._save_tokens(response.json()) def _do_refresh(self): response = self.session.post( url=self.config["endpoints"]["refresh"], json={"refreshToken": self.refresh_token}, headers=self.auth_header ) if response.status_code != 200: raise ValueError("Token refresh failed") self._save_tokens(response.json()) def _save_tokens(self, data: dict): self.access_token = data["accessToken"] self.refresh_token = data["refreshToken"] expires_in = data.get("expires_in", 3600) self.token_expiration = int(time.time()) + expires_in self.session.headers.update(self.auth_header) def _parse_select(self, ep_id: str, short_number: str, season_num: int) -> bool: """Retourne True si l'épisode doit être inclus.""" if self.all_eps or not self.select_str: return True # Préparation des identifiants possibles pour cet épisode # On teste : "30353" (id), "1" (numéro), "S02E01" (format complet), "S02" (saison entière) candidates = [ str(ep_id), str(short_number).lstrip("0"), f"S{season_num:02d}E{int(short_number):02d}" if str(short_number).isdigit() else "", f"S{season_num:02d}" ] parts = re.split(r'[ ,]+', self.select_str.strip().upper()) selection: set[str] = set() for part in parts: if '-' in part: start_p, end_p = part.split('-', 1) # Gestion des plages S02E01-S02E04 m_start = re.match(r'^S(\d+)E(\d+)$', start_p) m_end = re.match(r'^S(\d+)E(\d+)$', end_p) if m_start and m_end: s_start, e_start = map(int, m_start.groups()) s_end, e_end = map(int, m_end.groups()) if s_start == s_end: # Même saison for i in range(e_start, e_end + 1): selection.add(f"S{s_start:02d}E{i:02d}") continue # Plages classiques (1-10) nums = re.findall(r'\d+', part) if len(nums) >= 2: for i in range(int(nums[0]), int(nums[1]) + 1): selection.add(str(i)) else: selection.add(part.lstrip("0")) included = any(c in selection for c in candidates if c) return not included if self.but else included def get_titles(self) -> Series: """Récupère les épisodes avec le titre réel de la série.""" show_id = self.parse_show_id(self.title) # 1. Récupérer d'abord les infos globales du show pour avoir le titre propre show_url = self.config["endpoints"]["show"].format(show_id=show_id) show_res = self.session.get(show_url).json() # On extrait le titre de la série (ex: "Demon Slave") # C'est ce titre qui servira de nom au dossier unique series_title = show_res["videos"][0]["show"]["title"] if show_res.get("videos") else "ADN Show" # 2. Récupérer ensuite la structure par saisons url_seasons = self.config["endpoints"].get("seasons") if not url_seasons: url_seasons = "https://gw.api.animationdigitalnetwork.com/video/show/{show_id}/seasons?maxAgeCategory=18&order=asc" res = self.session.get(url_seasons.format(show_id=show_id)).json() if not res.get("seasons"): self.log.error(f"Aucune saison trouvée pour l'ID {show_id}") return Series([]) episodes = [] for season_data in res["seasons"]: s_val = str(season_data.get("season", "1")) season_num = int(s_val) if s_val.isdigit() else 1 for vid in season_data.get("videos", []): video_id = str(vid["id"]) # Nettoyage du numéro d'épisode (on ne garde que les chiffres) num_match = re.search(r'\d+', str(vid.get("number", "0"))) short_number = num_match.group() if num_match else "0" # Logique de sélection (SxxEyy) if not self._parse_select(video_id, short_number, season_num): continue # Création de l'épisode episodes.append(Episode( id_=video_id, service=self.__class__, title=series_title, # Dossier : "Demon Slave" season=season_num, # Saison : 2 number=int(short_number), name=vid.get("name") or "", # Nom : "La grande réunion..." data=vid )) episodes.sort(key=lambda x: (x.season, x.number)) return Series(episodes) def get_tracks(self, title: Episode) -> Tracks: """ Récupère les pistes en pré-extrayant les audios. Les audios sont extraits maintenant et seront copiés pendant download(). """ self.ensure_authenticated() vid_id = title.id # Configuration du lecteur config_url = self.config["endpoints"]["player_config"].format(video_id=vid_id) config_res = self.session.get(config_url).json() player_opts = config_res["player"]["options"] if not player_opts["user"]["hasAccess"]: raise PermissionError("No access to this video (Premium required?)") # Token du lecteur refresh_url = player_opts["user"].get("refreshTokenUrl") or self.config["endpoints"]["player_refresh"] token_res = self.session.post( refresh_url, headers={"X-Player-Refresh-Token": player_opts["user"]["refreshToken"]} ).json() player_token = token_res["token"] links_url = player_opts["video"].get("url") or self.config["endpoints"]["player_links"].format(video_id=vid_id) # Chiffrement RSA rand_key = uuid.uuid4().hex[:16] payload = json.dumps({"k": rand_key, "t": player_token}).encode('utf-8') public_key = serialization.load_pem_public_key( self.RSA_PUBLIC_KEY.encode('utf-8'), backend=default_backend() ) encrypted = public_key.encrypt(payload, padding.PKCS1v15()) auth_header_val = base64.b64encode(encrypted).decode('utf-8') # Récupération des liens links_res = self.session.get( links_url, params={"freeWithAds": "true", "adaptive": "true", "withMetadata": "true", "source": "Web"}, headers={"X-Player-Token": auth_header_val} ).json() tracks = Tracks() streaming_links = links_res.get("links", {}).get("streaming", {}) # Map des langues lang_map = { "vf": "fr", "vostf": "ja", "vde": "de", "vostde": "ja", } # Priorité: VOSTF (original) pour la vidéo principale priority_order = ["vostf", "vf", "vde", "vostde"] available_streams = {k: v for k, v in streaming_links.items() if k in lang_map} sorted_streams = sorted( available_streams.keys(), key=lambda x: priority_order.index(x) if x in priority_order else 999 ) if not sorted_streams: raise ValueError("No supported streams found") # Vidéo principale (VOSTF ou premier disponible) primary_stream = sorted_streams[0] primary_lang = lang_map[primary_stream] self.log.info(f"Primary video stream: {primary_stream} ({primary_lang})") video_track = self._get_video_track( streaming_links[primary_stream], primary_stream, primary_lang, is_original=(primary_stream in ["vostf", "vostde"]) ) if video_track: tracks.add(video_track) self.log.info(f"Video track added: {video_track.width}x{video_track.height}") # Extraire audios pour toutes les langues disponibles for stream_type in sorted_streams: audio_lang = lang_map[stream_type] is_original = stream_type in ["vostf", "vostde"] self.log.info(f"Processing audio for: {stream_type} ({audio_lang})") audio_track = self._extract_audio_track( streaming_links[stream_type], stream_type, audio_lang, is_original, title ) if audio_track: tracks.add(audio_track, warn_only=True) self.log.info(f"Audio track added: {audio_lang}") # Stocker les données de chapitres pour get_chapters() if "video" in links_res: title.data["chapter_data"] = links_res["video"] self.log.debug(f"Stored chapter data: intro={links_res['video'].get('tcIntroStart')}, ending={links_res['video'].get('tcEndingStart')}") # Sous-titres self._process_subtitles(links_res, rand_key, title, tracks) if not tracks.videos: raise ValueError("No video tracks were successfully added") return tracks def _get_video_track(self, stream_data: dict, stream_type: str, lang: str, is_original: bool): """Récupère la piste vidéo principale (sans audio).""" try: m3u8_url = self._resolve_stream_url(stream_data, stream_type) if not m3u8_url: return None hls_manifest = HLS.from_url(url=m3u8_url, session=self.session) hls_tracks = hls_manifest.to_tracks(language=lang) if not hls_tracks.videos: self.log.warning(f"No video tracks found for {stream_type}") return None # Meilleure qualité best_video = max( hls_tracks.videos, key=lambda v: (v.height or 0, v.width or 0, v.bitrate or 0) ) # Convertir en VideoNoAudio pour demuxer automatiquement video_no_audio = VideoNoAudio( id_=best_video.id, url=best_video.url, codec=best_video.codec, language=Language.get(lang), is_original_lang=is_original, bitrate=best_video.bitrate, descriptor=best_video.descriptor, width=best_video.width, height=best_video.height, fps=best_video.fps, range_=best_video.range, data=best_video.data, ) video_no_audio.data["stream_type"] = stream_type return video_no_audio except Exception as e: self.log.error(f"Failed to get video track for {stream_type}: {e}") return None def _extract_audio_track(self, stream_data: dict, stream_type: str, lang: str, is_original: bool, title: Episode): """ Extrait l'audio et retourne un AudioExtracted. L'audio est extrait MAINTENANT et sera copié pendant download(). """ if not binaries.FFMPEG: self.log.warning("FFmpeg not found, cannot extract audio") return None try: m3u8_url = self._resolve_stream_url(stream_data, stream_type) if not m3u8_url: return None # Créer un répertoire temp pour ADN dans le temp d'Unshackle adn_temp = config.directories.temp / "adn_audio_extracts" adn_temp.mkdir(parents=True, exist_ok=True) # Nom de fichier unique basé sur video_id + langue audio_filename = f"audio_{title.id}_{stream_type}.m4a" audio_path = adn_temp / audio_filename # Si déjà extrait, réutiliser if audio_path.exists() and audio_path.stat().st_size > 1000: self.log.debug(f"Reusing existing extracted audio: {audio_path}") else: # Extraire avec FFmpeg result = subprocess.run( [ binaries.FFMPEG, '-i', m3u8_url, '-vn', '-acodec', 'copy', '-y', str(audio_path) ], capture_output=True, text=True, timeout=300 ) if result.returncode != 0: self.log.error(f"FFmpeg failed for {stream_type}: {result.stderr}") audio_path.unlink(missing_ok=True) return None if not audio_path.exists() or audio_path.stat().st_size < 1000: self.log.error(f"Extracted audio is invalid for {stream_type}") audio_path.unlink(missing_ok=True) return None # Créer AudioExtracted avec le fichier pré-extrait audio_track = AudioExtracted( id_=f"audio-{stream_type}-{lang}", extracted_path=audio_path, codec=Audio.Codec.AAC, language=Language.get(lang), is_original_lang=is_original, bitrate=128000, channels=2.0, ) return audio_track except subprocess.TimeoutExpired: self.log.error(f"FFmpeg timeout for {stream_type}") return None except Exception as e: self.log.error(f"Failed to extract audio for {stream_type}: {e}") return None def _resolve_stream_url(self, stream_data: dict, stream_type: str) -> Optional[str]: """Résout l'URL du stream.""" preferred_keys = ["fhd", "hd", "auto", "sd", "mobile"] m3u8_url = None for key in preferred_keys: if key in stream_data and stream_data[key]: m3u8_url = stream_data[key] break if not m3u8_url: return None try: resp = self.session.get(m3u8_url, timeout=12) if resp.status_code != 200: return None content_type = resp.headers.get("Content-Type", "") resp_text = resp.text.strip() if "application/json" in content_type or resp_text.startswith("{"): try: json_data = resp.json() real_location = json_data.get("location") if real_location: return real_location except json.JSONDecodeError: pass return m3u8_url except Exception as e: self.log.error(f"Failed to resolve URL for {stream_type}: {e}") return None def _process_subtitles(self, links_res: dict, rand_key: str, title: Episode, tracks: Tracks): """Traite les sous-titres.""" subs_root = links_res.get("links", {}).get("subtitles", {}) if "all" not in subs_root: self.log.debug("No subtitles available") return aes_key_bytes = bytes.fromhex(rand_key + '7fac1178830cfe0c') try: sub_loc_res = self.session.get(subs_root["all"]).json() encrypted_sub_res = self.session.get(sub_loc_res["location"]).text self.log.debug(f"Encrypted subtitle length: {len(encrypted_sub_res)}") iv_b64 = encrypted_sub_res[:24] payload_b64 = encrypted_sub_res[24:] iv = base64.b64decode(iv_b64) ciphertext = base64.b64decode(payload_b64) self.log.debug(f"IV length: {len(iv)}, Ciphertext length: {len(ciphertext)}") cipher = Cipher(algorithms.AES(aes_key_bytes), modes.CBC(iv), backend=default_backend()) decryptor = cipher.decryptor() decrypted_padded = decryptor.update(ciphertext) + decryptor.finalize() # TOUJOURS retirer le padding PKCS7 (Python ne le fait pas automatiquement) pad_len = decrypted_padded[-1] if not (1 <= pad_len <= 16): self.log.error(f"Invalid PKCS7 padding length: {pad_len}") return # Vérifier que tous les bytes de padding ont la même valeur padding = decrypted_padded[-pad_len:] if not all(b == pad_len for b in padding): self.log.error(f"Invalid PKCS7 padding bytes") return decrypted_json = decrypted_padded[:-pad_len].decode('utf-8') self.log.debug(f"Decrypted JSON length: {len(decrypted_json)}") subs_data = json.loads(decrypted_json) if not isinstance(subs_data, dict): self.log.error(f"subs_data is not a dict! Type: {type(subs_data)}") return if len(subs_data) == 0: self.log.warning("subs_data is empty!") return # Debug chaque clé for key in subs_data.keys(): value = subs_data[key] if isinstance(value, list) and len(value) > 0: self.log.debug(f" First item type: {type(value[0])}") self.log.debug(f" First item keys: {value[0].keys() if isinstance(value[0], dict) else 'NOT A DICT'}") self.log.debug(f" First item sample: {str(value[0])[:200]}") processed_langs = set() for sub_lang_key, cues in subs_data.items(): if not isinstance(cues, list): self.log.warning(f"Cues for {sub_lang_key} is not a list! Type: {type(cues)}") continue if len(cues) == 0: self.log.debug(f"No subtitles for {sub_lang_key} (normal for dubbed versions)") continue self.log.debug(f" Cues count: {len(cues)}") self.log.debug(f" First cue: {cues[0]}") if "vf" in sub_lang_key.lower() or "vostf" in sub_lang_key.lower(): target_lang = "fr" elif "vde" in sub_lang_key.lower() or "vostde" in sub_lang_key.lower(): target_lang = "de" else: self.log.debug(f"Skipping subtitle language: {sub_lang_key}") continue if target_lang in processed_langs: self.log.debug(f"Already processed {target_lang}, skipping") continue processed_langs.add(target_lang) # Convertir en ASS ass_content = self._json_to_ass(cues, title.title, title.number) # Vérifier si le fichier ASS a du contenu event_count = ass_content.count("Dialogue:") self.log.debug(f"Generated ASS with {event_count} dialogue events") if event_count == 0: self.log.warning(f"ASS file has no dialogue events!") self.log.warning(f"First cue was: {cues[0] if cues else 'EMPTY LIST'}") # Créer SubtitleEmbedded avec le contenu ASS directement subtitle = SubtitleEmbedded( id_=f"sub-{target_lang}-{sub_lang_key}", embedded_content=ass_content, # Contenu ASS directement codec=Subtitle.Codec.SubStationAlphav4, language=Language.get(target_lang), forced=False, sdh=False, ) tracks.add(subtitle, warn_only=True) self.log.info(f"Subtitle added: {target_lang} ({event_count} events)") except json.JSONDecodeError as e: self.log.error(f"Failed to decode JSON: {e}") self.log.error(f"Decrypted data (first 500 chars): {decrypted_json[:500] if 'decrypted_json' in locals() else 'NOT DECRYPTED'}") except Exception as e: self.log.error(f"Failed to process subtitles: {e}") import traceback self.log.debug(traceback.format_exc()) def get_chapters(self, title: Episode) -> Chapters: """ Crée les chapitres à partir des timecodes ADN. - Si tcIntroStart existe: - Si tcIntroStart != "00:00:00": ajouter "Prologue" à 00:00:00 - Ajouter "Opening" à tcIntroStart - Ajouter "Episode" à tcIntroEnd - Sinon: ajouter "Episode" à 00:00:00 - Si tcEndingStart existe: - Ajouter "Ending Start" à tcEndingStart - Ajouter "Ending End" à tcEndingEnd """ chapters = Chapters() # Récupérer les données de chapitres stockées dans get_tracks() chapter_data = title.data.get("chapter_data", {}) if not chapter_data: self.log.debug("No chapter data available") return chapters tc_intro_start = chapter_data.get("tcIntroStart") tc_intro_end = chapter_data.get("tcIntroEnd") tc_ending_start = chapter_data.get("tcEndingStart") tc_ending_end = chapter_data.get("tcEndingEnd") self.log.debug(f"Chapter timecodes: intro={tc_intro_start}->{tc_intro_end}, ending={tc_ending_start}->{tc_ending_end}") try: if tc_intro_start: # Si l'intro ne commence pas à 00:00:00, ajouter un prologue if tc_intro_start != "00:00:00": chapters.add(Chapter( timestamp=0, name="Prologue" )) self.log.debug("Added Prologue chapter at 00:00:00") # Opening chapters.add(Chapter( timestamp=self._timecode_to_ms(tc_intro_start), name="Opening" )) self.log.debug(f"Added Opening chapter at {tc_intro_start}") # Episode (après l'intro) if tc_intro_end: chapters.add(Chapter( timestamp=self._timecode_to_ms(tc_intro_end), name="Episode" )) self.log.debug(f"Added Episode chapter at {tc_intro_end}") else: # Pas d'intro, épisode commence à 00:00:00 chapters.add(Chapter( timestamp=0, name="Episode" )) self.log.debug("Added Episode chapter at 00:00:00 (no intro)") # Ending if tc_ending_start: chapters.add(Chapter( timestamp=self._timecode_to_ms(tc_ending_start), name="Ending Start" )) self.log.debug(f"Added Ending Start chapter at {tc_ending_start}") if tc_ending_end: chapters.add(Chapter( timestamp=self._timecode_to_ms(tc_ending_end), name="Ending End" )) self.log.debug(f"Added Ending End chapter at {tc_ending_end}") self.log.info(f"✓ Created {len(chapters)} chapters") except Exception as e: self.log.error(f"Failed to create chapters: {e}") import traceback self.log.debug(traceback.format_exc()) return chapters def search(self) -> Generator[SearchResult, None, None]: res = self.session.get( self.config["endpoints"]["search"], params={"search": self.title, "limit": 20, "offset": 0} ).json() for show in res.get("shows", []): yield SearchResult( id_=str(show["id"]), title=show["title"], label=show["type"], description=show.get("summary", "")[:300], url=f"https://animationdigitalnetwork.com/video/{show['id']}", image=show.get("image") ) def parse_show_id(self, input_str: str) -> str: if input_str.isdigit(): return input_str match = re.match(self.TITLE_RE, input_str) if match: return match.group("id") raise ValueError(f"Invalid ADN Show ID/URL: {input_str}") def _json_to_ass(self, cues: List[dict], title: str, ep_num: Union[int, str]) -> str: """Convertit les sous-titres JSON en ASS.""" header = """[Script Info] ScriptType: v4.00+ WrapStyle: 0 PlayResX: 1280 PlayResY: 720 ScaledBorderAndShadow: yes [V4+ Styles] Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding Style: Default,Arial,50,&H00FFFFFF,&H00FFFFFF,&H00000000,&H00000000,-1,0,0,0,100,100,0,0,1,1.95,0,2,0,0,70,0 [Events] Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text """ events = [] pos_align_map = {"start": 1, "end": 3} line_align_map = {"middle": 8, "end": 4} def format_time(seconds: float) -> str: """Format exact d'adn : HH:MM:SS.CC (centisecondes sur 2 chiffres)""" secs = int(seconds) centiseconds = round((seconds - secs) * 100) hours = secs // 3600 minutes = (secs % 3600) // 60 remaining_seconds = secs % 60 # Padding sur 2 chiffres pour TOUT (hours inclus) return f"{hours:02d}:{minutes:02d}:{remaining_seconds:02d}.{centiseconds:02d}" for cue in cues: start_time = cue.get("startTime", 0) end_time = cue.get("endTime", 0) text = cue.get("text", "") # Skip si texte vide if not text or not text.strip(): continue # Nettoyage EXACT du code adn text = text.replace(' \\N', '\\N') # remove space before \\N at end if text.endswith('\\N'): text = text[:-2] # remove \\N at end text = text.replace('\r', '') text = text.replace('\n', '\\N') text = re.sub(r'\\N +', r'\\N', text) # \\N followed by spaces text = re.sub(r' +\\N', r'\\N', text) # spaces followed by \\N text = re.sub(r'(\\N)+', r'\\N', text) # multiple \\N text = re.sub(r']*>([^<]*)', r'{\\b1}\1{\\b0}', text) text = re.sub(r']*>([^<]*)', r'{\\i1}\1{\\i0}', text) text = re.sub(r']*>([^<]*)', r'{\\u1}\1{\\u0}', text) text = text.replace('<', '<').replace('>', '>').replace('&', '&') text = re.sub(r'<[^>]>', '', text) # remove any remaining single tags if text.endswith('\\N'): text = text[:-2] text = text.rstrip() # remove trailing spaces # Skip après nettoyage si vide if not text.strip(): continue p_align = pos_align_map.get(cue.get("positionAlign"), 2) l_align = line_align_map.get(cue.get("lineAlign", ""), 0) align_val = p_align + l_align start = format_time(start_time) end = format_time(end_time) style_mod = f"{{\\a{align_val}}}" if align_val != 2 else "" events.append(f"Dialogue: 0,{start},{end},Default,,0,0,0,,{style_mod}{text}") self.log.debug(f"Converted {len(events)} subtitle events from {len(cues)} cues") if not events: self.log.warning(f"No subtitle events generated - all cues were empty or invalid (total cues: {len(cues)})") return header + "\n".join(events)