diff --git a/ADN/__init__.py b/ADN/__init__.py new file mode 100644 index 0000000..d356934 --- /dev/null +++ b/ADN/__init__.py @@ -0,0 +1,1015 @@ +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) \ No newline at end of file diff --git a/ADN/__pycache__/__init__.cpython-310.pyc b/ADN/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..f7177bd Binary files /dev/null and b/ADN/__pycache__/__init__.cpython-310.pyc differ diff --git a/ADN/config.yaml b/ADN/config.yaml new file mode 100644 index 0000000..3e77996 --- /dev/null +++ b/ADN/config.yaml @@ -0,0 +1,29 @@ +# Animation Digital Network API Configuration + +# Endpoints API +endpoints: + # Authentification + login: "https://gw.api.animationdigitalnetwork.com/authentication/login" + refresh: "https://gw.api.animationdigitalnetwork.com/authentication/refresh" + +# Catalogue + search: "https://gw.api.animationdigitalnetwork.com/show/catalog" + show: "https://gw.api.animationdigitalnetwork.com/video/show/{show_id}?maxAgeCategory=18&limit=-1&order=asc" + seasons: "https://gw.api.animationdigitalnetwork.com/video/show/{show_id}/seasons?maxAgeCategory=18&order=asc" + +# Player & Lecture + player_config: "https://gw.api.animationdigitalnetwork.com/player/video/{video_id}/configuration" + player_refresh: "https://gw.api.animationdigitalnetwork.com/player/refresh/token" + player_links: "https://gw.api.animationdigitalnetwork.com/player/video/{video_id}/link" + +# Headers par défaut +headers: + User-Agent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" + Origin: "https://animationdigitalnetwork.com" + Referer: "https://animationdigitalnetwork.com/" + Content-Type: "application/json" + X-Target-Distribution: "fr" + +# Paramètres +params: + locale: "fr" \ No newline at end of file