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 unshackle.core.constants import AnyTrack from unshackle.core.credential import Credential from unshackle.core.manifests.dash import DASH from unshackle.core.service import Service from unshackle.core.titles import Title_T from unshackle.core.titles.episode import Episode, Series from unshackle.core.titles.movie import Movie, Movies from unshackle.core.tracks.subtitle import Subtitle from unshackle.core.tracks.tracks import Tracks from unshackle.core.tracks.video import Video from unshackle.core.utilities import is_close_match from unshackle.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