From e9b2aee910b55d0c437d4dce65a83bfb07430973 Mon Sep 17 00:00:00 2001 From: Mike Date: Tue, 20 Jan 2026 09:02:07 +0200 Subject: [PATCH] Added ClaroVideo Services thanks to Dex --- CV/__init__.py | 392 ++++++++++++++++++++++++ CV/__pycache__/__init__.cpython-312.pyc | Bin 0 -> 22028 bytes CV/config.yaml | 33 ++ 3 files changed, 425 insertions(+) create mode 100644 CV/__init__.py create mode 100644 CV/__pycache__/__init__.cpython-312.pyc create mode 100644 CV/config.yaml diff --git a/CV/__init__.py b/CV/__init__.py new file mode 100644 index 0000000..0977081 --- /dev/null +++ b/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 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 \ No newline at end of file diff --git a/CV/__pycache__/__init__.cpython-312.pyc b/CV/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..731128ed4653c09a2be14843b9bb2e577ffc17f0 GIT binary patch literal 22028 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 ae8|