diff --git a/packages/envied/src/envied/services/ADN/__init__.py b/packages/envied/src/envied/services/ADN/__init__.py new file mode 100644 index 0000000..30b7a51 --- /dev/null +++ b/packages/envied/src/envied/services/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 envied.core import binaries +from envied.core.config import config +from envied.core.manifests import HLS +from envied.core.search_result import SearchResult +from envied.core.service import Service +from envied.core.session import session +from envied.core.titles import Episode, Series +from envied.core.tracks import Tracks, Chapters, Chapter +from envied.core.tracks.audio import Audio +from envied.core.tracks.video import Video +from envied.core.tracks.audio import Audio +from envied.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/packages/envied/src/envied/services/ADN/config.yaml b/packages/envied/src/envied/services/ADN/config.yaml new file mode 100644 index 0000000..3e77996 --- /dev/null +++ b/packages/envied/src/envied/services/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 diff --git a/packages/envied/src/envied/services/CV/__init__.py b/packages/envied/src/envied/services/CV/__init__.py new file mode 100644 index 0000000..73ca25e --- /dev/null +++ b/packages/envied/src/envied/services/CV/__init__.py @@ -0,0 +1,392 @@ +import base64 +from hashlib import md5 +from http.cookiejar import CookieJar +import json +import re +import sys +from typing import Optional, Union +import click +from langcodes import Language +import requests +from envied.core.constants import AnyTrack +from envied.core.credential import Credential +from envied.core.manifests.dash import DASH +from envied.core.service import Service +from envied.core.titles import Title_T +from envied.core.titles.episode import Episode, Series +from envied.core.titles.movie import Movie, Movies +from envied.core.tracks.subtitle import Subtitle +from envied.core.tracks.tracks import Tracks +from envied.core.tracks.video import Video +from envied.core.utilities import is_close_match +from envied.core.utils.collections import as_list + + +class CV(Service): + """ + Service code for ClaroVideo streaming service (https://www.clarovideo.com). + + \b + Authorization: Credentials + Security: FHD@L3 + """ + + ALIASES = ("CV", "ClaroVideo", "CLVD") + #TITLE_RE = [r"https?://(?:www\.)?clarovideo.com/(?P[\w-]+)/vcard/(?:[\w-]+/)?(?P\d+)"] + TITLE_RE = r"https?://(?:www\.)?clarovideo\.com/(?P[\w-]+)/vcard/(?:.*/)?(?P\d+)/?$" + LANGUAGE_MAP = { + "AR": "es-AR", "BO": "es-BO", "BR": "pt-BR", "CA": "en-CA", "CL": "es-CL", + "CO": "es-CO", "CR": "es-CR", "CU": "es-CU", "DO": "es-DO", "EC": "es-EC", + "GT": "es-GT", "HN": "es-HN", "MX": "es-MX", "NI": "es-NI", "PA": "es-PA", + "PE": "es-PE", "PR": "es-PR", "PY": "es-PY", "SV": "es-SV", "US": "en-US", + "UY": "es-UY", "VE": "es-VE", "AT": "de-AT", "BE": "nl-BE", "BG": "bg-BG", + "CH": "de-CH", "CZ": "cs-CZ", "DE": "de-DE", "DK": "da-DK", "EE": "et-EE", + "ES": "es-ES", "FI": "fi-FI", "FR": "fr-FR", "GB": "en-GB", "UK": "en-GB", + "GR": "el-GR", "HR": "hr-HR", "HU": "hu-HU", "IE": "en-IE", "IS": "is-IS", + "IT": "it-IT", "LT": "lt-LT", "LU": "lb-LU", "LV": "lv-LV", "MT": "mt-MT", + "NL": "nl-NL", "NO": "nb-NO", "PL": "pl-PL", "PT": "pt-PT", "RO": "ro-RO", + "RU": "ru-RU", "SE": "sv-SE", "SI": "sl-SI", "SK": "sk-SK", "UA": "uk-UA", + "AE": "ar-AE", "CN": "zh-CN", "HK": "zh-HK", "ID": "id-ID", "IL": "he-IL", + "IN": "hi-IN", "IQ": "ar-IQ", "IR": "fa-IR", "JP": "ja-JP", "KH": "km-KH", + "KR": "ko-KR", "KW": "ar-KW", "MY": "ms-MY", "PH": "fil-PH", "PK": "ur-PK", + "QA": "ar-QA", "SA": "ar-SA", "SG": "en-SG", "SY": "ar-SY", "TH": "th-TH", + "TR": "tr-TR", "TW": "zh-TW", "VN": "vi-VN", "DZ": "ar-DZ", "EG": "ar-EG", + "ET": "am-ET", "GH": "en-GH", "KE": "sw-KE", "LY": "ar-LY", "MA": "ar-MA", + "MU": "en-MU", "NG": "en-NG", "TN": "ar-TN", "ZA": "en-ZA", "AU": "en-AU", + "FJ": "en-FJ", "NZ": "en-NZ", "PG": "en-PG" + } + + + @staticmethod + @click.command(name="CV", short_help="https://www.clarovideo.com") + @click.argument("title", type=str, required=False) + @click.option("--master", type=str, required=False, default="ORIGINAL", help="Get the selected master") + @click.pass_context + def cli(ctx, **kwargs): + return CV(ctx, **kwargs) + + def __init__(self, ctx, title: str, master: str): + super().__init__(ctx) + m = self.parse_title(ctx, title) + #self.movie = movie or m.get("type") == "filme" + self.region = m["region"] + self.master = master + + self.log.warning(f"Selected Master: '{self.master}'") + +## Service specific methods + def authenticate(self, cookies: Optional[CookieJar] = None, credential: Optional[Credential] = None) -> None: + super().authenticate(cookies, credential) + if not credential: + raise EnvironmentError("Service requires Credentials for login.") + + # configure account service + self.configure() + + def get_titles(self): + self.config["params"]["group_id"] = self.title + + try: + res = self.session.get( + url=self.config["endpoints"]["data"], + params=self.config["params"] + ) + res.raise_for_status() + data_full = res.json() + metadata = data_full["response"]["group"]["common"] + except Exception as e: + self.log.error(f" + Failed to retrieve title metadata: {e}") + raise + + # Referencias directas para evitar accesos repetitivos + media = metadata["extendedcommon"]["media"] + self.encode = "dashwv_ma" + self.movie = "episode" not in media + + title_name = media["originaltitle"] + release_year = media["publishyear"] + + # Limpieza en la obtención del lenguaje + country_code = str(media.get("countryoforigin", {}).get("code", "")).upper() + original_lang = self.LANGUAGE_MAP.get(country_code, "en") + self.log.info(f"Original Language: {original_lang}") + + if self.movie: + return Movies([ + Movie( + id_=metadata["id"], + service=self.__class__, + name=title_name, + year=release_year, + language=original_lang, + data=metadata + ) + ]) + + + # TV Shows - Novels and Series + titles = [] + + try: + self.config["params"]["group_id"] = self.title + response = self.session.get( + url=self.config["endpoints"]["serie"], + params=self.config["params"], + ) + response.raise_for_status() + data = response.json() + + except Exception as e: + self.log.error(f" + Failed to retrieve title metadata: {e}") + raise e + + else: + try: + seasons = data["response"]["seasons"] + for season in seasons: + for episode in season["episodes"]: + titles.append( + Episode( + id_=episode["id"], + service=self.__class__, + title=title_name, + season=episode['season_number'], + number=episode['episode_number'], + name=episode['title_episode'], + year=release_year, + language=original_lang, + data=episode, + ) + ) + except KeyError as e: + self.log.error(f" + API response structure changed: Missing key {e}") + raise e + + return Series(titles) + + def get_tracks(self, title: Title_T) -> Tracks: + #Define individual parameters for payway and data endpoints + payway_params = self.config["payway_params"].copy() + self.config["params"]["group_id"] = title.id + payway_params["group_id"] = title.id + + # Request payway token + response = self.session.get( + url=self.config["endpoints"]["payway"], + params=payway_params, + ).json() + + if not response.get("response", {}).get("playButton", {}).get("payway_token"): + self.log.warning("The user does not have access to this content") + sys.exit(1) + + payway_token = response["response"]["playButton"]["payway_token"] + + # Request title data + response = self.session.get(url=self.config["endpoints"]["data"], params=self.config["params"]).json() + title_data = response["response"]["group"]["common"] + + title_audios = [ + x + for x in title_data["extendedcommon"]["media"]["language"]["options"]["option"] + if not x["option_name"] == "subbed" + ] + + original_master = next((x for x in title_audios if x["audio"] == "ORIGINAL"), None) + if not original_master: + self.log.warning("Original master not found.") + original_master = title_audios[0] + + original_encode = "dashwv_ma" if "dashwv_ma" in original_master["encodes"] else "dashwv" + payway_params["stream_type"] = original_encode + payway_params["user_hash"] = self.user_info["session_userhash"] + + response = self.session.post( + url=self.config["endpoints"]["media"], + params=payway_params, + data={"user_token": self.user_info["user_token"], "payway_token": payway_token}, + ).json() + + if not response.get("response"): + raise ValueError(response) + + original_manifest = response["response"] + _ = self.session.get(original_manifest["tracking"]["urls"]["stop"], params={"timecode": 0}).json() + + missing_audio = [ + x for x in title_audios if x["audio"] not in original_manifest["media"].get("audio", {}).get("options", []) + ] + if missing_audio and not next((x for x in missing_audio if x["audio"] == self.master), None): + self.log.warning( + f"This title has {len(missing_audio) + 1} separate Manifests, alternative master found: " + f"{[x['audio'] for x in missing_audio]}, " + f"you can select master with the --master flag" + ) + + manifest = original_manifest + if not self.master == "ORIGINAL": + _ = self.session.get(original_manifest["tracking"]["urls"]["dubsubchange"], params={"timecode": 0}).json() + + master_info = next((x for x in original_manifest['language']['options'] if x["option_id"] == f"D-{self.master}"), None) + if not master_info: + raise ValueError( + f"Master '{self.master}' not found, available masters: {', '.join(x['audio'] for x in title_audios)}" + ) + + encode = "dashwv_ma" if "dashwv_ma" in master_info["encodes"] else "dashwv" + + payway_params["content_id"] = master_info['content_id'] + payway_params["preferred_audio"] = self.master + payway_params["stream_type"] = encode + payway_params["user_hash"] = self.user_info["session_userhash"] + + response = self.session.post( + url=self.config["endpoints"]["media"], + params=payway_params, + data={"user_token": self.user_info["user_token"], "payway_token": payway_token}, + ).json() + if not response.get("response"): + raise ValueError(response) + + manifest = response["response"] + _ = self.session.get(manifest["tracking"]["urls"]["stop"], params={"timecode": 0}).json() + self.log.info(manifest) + mpd_url = manifest["media"]["video_url"] + + manifest_language = ( + title.language + if manifest["media"].get("audio", {}).get("selected", "") == "ORIGINAL" + else manifest["media"].get("audio", {}).get("selected", "") + ) + + tracks = DASH.from_url(url=mpd_url, session=self.session).to_tracks(language=manifest_language) + + # remove subtitles track as they are not available in ClaroVideo DASH manifests + tracks.subtitles.clear() # No subtitles available in ClaroVideo DASH manifests + if manifest["media"].get("subtitles"): + for _, subtitle in manifest["media"]["subtitles"]["options"].items(): + tracks.add(Subtitle( + id_=md5(subtitle["external"].encode()).hexdigest(), + url=subtitle['external'], + # metadata + codec=Subtitle.Codec.WebVTT, + language=subtitle["internal"], + #is_original_lang=title.original_lang and is_close_match(sub["languageCode"], [title.original_lang]), + #forced="ForcedNarrative" in sub["type"], + #sdh=sub["type"].lower() == "sdh" # TODO: what other sub types? cc? forced? + ), warn_only=True) # expecting possible dupes, ignore + + # Extraemos los segundos del JSON + duration_in_seconds = manifest['media']['duration'].get('seconds', 0) + + for track in tracks: + track.extra = {"manifest": manifest} + #track.needs_proxy = True + if str(track.language) == "or" or str(track.language) == "und": + track.language = Language.get(manifest_language) + if str(track.language) == "pt": + track.language = Language.get("pt-BR") + if str(track.language) == "es": + track.language = Language.get("es-419") + + track.is_original_lang = is_close_match(track.language, [title.language]) + track.name = Language.get(track.language).display_name() + + #FileSize + if isinstance(track, Video) and duration_in_seconds > 0 and track.bitrate: + track.extra = {'size': int((track.bitrate * duration_in_seconds) / 8)} + + return tracks + + def get_chapters(self, title): + return [] + + def get_widevine_service_certificate(self, *, challenge: bytes, title, track) -> Optional[bytes]: + return None + + def get_widevine_license(self, *, challenge: bytes, title: Title_T, track: AnyTrack) -> Optional[Union[bytes, str]]: + challenge_b64 = base64.b64encode(challenge).decode() + + manifest_info = track.extra["manifest"] + challenge_info = json.loads(manifest_info["media"]["challenge"]) + + payload = {"token": challenge_info["token"], "device_id": self.device_id, "widevineBody": challenge_b64} + + response = requests.post( + url=manifest_info["media"]["server_url"], + headers={ + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:136.0) Gecko/20100101 Firefox/136.0", + "Referer": "https://www.clarovideo.com/", + "Origin": "https://www.clarovideo.com", + "Content-Type": "application/x-www-form-urlencoded", + }, + json=payload, + proxies=self.session.proxies, + ) + if not response.ok: + raise ValueError(response.text) + + return response.content + + def configure(self): + self.log.info(" + Logging in...") + + try: + self.session.headers.update({ + "Origin": "https://www.clarovideo.com", + "Referer": "https://www.clarovideo.com/", + }) + response = self.session.post( + url=self.config["endpoints"]["login"], + params=self.config["params"], + data={"username": self.credential.username, "password": self.credential.password}, + ) + + response.raise_for_status() + response = response.json() + + #self.log.info(json.dumps(response, indent=4)) + if "errors" in response: + self.log.error(f"Login failed: {response['errors']['error']}") + sys.exit(1) + + self.user_info = response["response"] + self.config["params"]["user_id"] = self.user_info["user_id"] + + self.device_id = self.get_device_id(self.user_info["session_stringvalue"]) + + self.config["payway_params"]["region"] = self.region + self.config["payway_params"]["device_id"] = self.device_id + self.config["payway_params"]["HKS"] = f"({self.user_info['session_stringvalue']})" + self.config["payway_params"]["user_id"] = self.user_info["user_id"] + self.log.info(" + Login successful") + + except Exception as e: + self.log.error(f" + Login failed: {e}") + + def get_device_id(self, user_hks) -> str: + self.config["params"]["HKS"] = user_hks + + response = self.session.post( + url=self.config["endpoints"]["device"], + params=self.config["params"], + ).json()["response"] + + device_id = next(x["real_device_id"] for x in response["devices"] if x["device_category"] == "web") + + return device_id + + def parse_title(self, ctx, title): + title = title or ctx.parent.params.get("title") + if not title: + self.log.error(" - No title ID provided") + if not getattr(self, "TITLE_RE"): + self.title = title + return {} + for regex in as_list(self.TITLE_RE): + m = re.search(regex, title) + if m: + self.title = m.group("id") + return m.groupdict() + self.log.warning(f" - Couldn't parse title ID from '{title!r}', using as-is") + self.title = title \ No newline at end of file diff --git a/packages/envied/src/envied/services/CV/config.yaml b/packages/envied/src/envied/services/CV/config.yaml new file mode 100644 index 0000000..eda6264 --- /dev/null +++ b/packages/envied/src/envied/services/CV/config.yaml @@ -0,0 +1,33 @@ +endpoints: + login: "https://mfwkweb-api.clarovideo.net/services/user/login" + headerinfo: "https://mfwkweb-api.clarovideo.net/services/user/startheaderinfo" + device: "https://mfwkweb-api.clarovideo.net/services/device/list" + serie: "https://mfwkweb-api.clarovideo.net/services/content/serie" + data: "https://mfwkweb-api.clarovideo.net/services/content/data" + media: "https://mfwkweb-api.clarovideo.net/services/player/getmedia" + payway: "https://mfwkweb-api.clarovideo.net/services/payway/purchasebuttoninfo" + certificate: "https://widevine-claroglobal-vod.clarovideo.net/licenser/getcertificate" + +params: + device_id: "web" + device_category: "web" + device_model: "web" + device_type: "web" + device_so: "Chrome" + format: "json" + device_manufacturer: "generic" + authpn: "webclient" + authpt: "tfg1h3j4k6fd7" + api_version: "v5.94" + HKS: "4nb186umaf9sfkiq2p1oakdeh6" + +payway_params: + api_version: "v5.94" + authpn: "html5player" + authpt: "ad5565dfgsftr" + format: "json" + device_category: "web" + device_model: "html5" + device_type: "html5" + device_so: "Chrome" + device_manufacturer: "windows"