From 28e5dcf3956b75eb9d464a1a4f75b8935779dce8 Mon Sep 17 00:00:00 2001 From: FairTrade Date: Wed, 5 Nov 2025 19:48:35 +0100 Subject: [PATCH] Updated KOWP to fetch brightcove config, Added HIDE, Updated readme --- HIDE/__init__.py | 236 +++++++++++++++++++++++++++++++++++++++++++++++ HIDE/config.yaml | 10 ++ KOWP/__init__.py | 40 ++++++-- KOWP/config.yaml | 4 - README.md | 25 ++++- 5 files changed, 301 insertions(+), 14 deletions(-) create mode 100644 HIDE/__init__.py create mode 100644 HIDE/config.yaml diff --git a/HIDE/__init__.py b/HIDE/__init__.py new file mode 100644 index 0000000..70b3276 --- /dev/null +++ b/HIDE/__init__.py @@ -0,0 +1,236 @@ +import json +import re +from http.cookiejar import CookieJar +from typing import Optional +from langcodes import Language +import base64 +import time + +import click + +from unshackle.core.constants import AnyTrack +from unshackle.core.credential import Credential +from unshackle.core.manifests import DASH +from unshackle.core.service import Service +from unshackle.core.titles import Episode, Series, Title_T, Titles_T +from unshackle.core.tracks import Chapter, Tracks, Subtitle + + +class HIDE(Service): + """ + Service code for HiDive (hidive.com) + Version: 1.0.0 + Auth: Credential (username + password) + Refresh token supported + Security: FHD@L3 + + Note: Only for series at the moment. + """ + + TITLE_RE = r"^https?://(?:www\.)?hidive\.com/(?:season/(?P\d+))$" + NO_SUBTITLES = False + + @staticmethod + @click.command(name="HIDE", short_help="https://hidive.com") + @click.argument("title", type=str) + @click.pass_context + def cli(ctx, **kwargs): + return HIDE(ctx, **kwargs) + + def __init__(self, ctx, title: str): + super().__init__(ctx) + m = re.match(self.TITLE_RE, title) + if not m: + raise ValueError(f"Unsupported HiDive URL: {title}\nUse: https://www.hidive.com/season/19079") + self.season_id = int(m.group("season_id")) + if not self.config: + raise EnvironmentError("Missing HIDE service config.") + self.cdm = ctx.obj.cdm + self._auth_token = None + self._refresh_token = None + self._drm_cache = {} + + def authenticate(self, cookies: Optional[CookieJar] = None, credential: Optional[Credential] = None) -> None: + base_headers = { + "User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:143.0) Gecko/20100101 Firefox/143.0", + "Accept": "application/json, text/plain, */*", + "Accept-Language": "en-US", + "Referer": "https://www.hidive.com/", + "Origin": "https://www.hidive.com", + "x-api-key": self.config["x_api_key"], + "app": "dice", + "Realm": "dce.hidive", + "x-app-var": self.config["x_app_var"], + } + self.session.headers.update(base_headers) + + if not credential or not credential.username or not credential.password: + raise ValueError("HiDive requires email + password (--credential 'email:password')") + + r_login = self.session.post( + self.config["endpoints"]["login"], + json={"id": credential.username, "secret": credential.password} + ) + if r_login.status_code == 401: + raise PermissionError("Invalid email or password.") + r_login.raise_for_status() + + login_data = r_login.json() + self._auth_token = login_data["authorisationToken"] + self._refresh_token = login_data["refreshToken"] + + self.session.headers["Authorization"] = f"Bearer {self._auth_token}" + self.log.info("HiDive login successful.") + + def _refresh_auth(self): + """REFRESH THE AUTH TOKEN to prevent 401 errors.""" + if not self._refresh_token: + raise PermissionError("No refresh token available to renew session.") + + self.log.warning("Auth token expired, refreshing...") + r = self.session.post( + self.config["endpoints"]["refresh"], + json={"refreshToken": self._refresh_token} + ) + if r.status_code == 401: + raise PermissionError("Refresh token is invalid. Please log in again.") + r.raise_for_status() + + data = r.json() + self._auth_token = data["authorisationToken"] + self.session.headers["Authorization"] = f"Bearer {self._auth_token}" + self.log.info("Auth token refreshed successfully.") + + def _api_get(self, url, **kwargs): + """Wrapper for GET requests that handles token refresh.""" + response = self.session.get(url, **kwargs) + if response.status_code == 401: + self._refresh_auth() + response = self.session.get(url, **kwargs) # Retry after refresh + response.raise_for_status() + return response + + def get_titles(self) -> Titles_T: + response = self._api_get( + self.config["endpoints"]["season_view"], + params={"type": "season", "id": self.season_id, "timezone": "Europe/Amsterdam"} + ) + data = response.json() + + episodes = [] + for elem in data.get("elements", []): + if elem.get("$type") == "bucket" and elem["attributes"].get("type") == "season": + for item in elem["attributes"].get("items", []): + if item.get("type") != "SEASON_VOD": + continue + ep_title = item["title"] + ep_num = 1 + if ep_title.startswith("E") and " - " in ep_title: + try: ep_num = int(ep_title.split(" - ")[0][1:]) + except: pass + episodes.append( + Episode( + id_=item["id"], service=self.__class__, title=data["metadata"]["series"]["title"], + season=1, number=ep_num, name=item["title"], + description=item.get("description", ""), language=Language.get("en"), data=item + ) + ) + break + + if not episodes: raise ValueError("No episodes found in season data.") + series_title = data["metadata"]["series"]["title"] + for ep in episodes: ep.title = series_title + return Series(sorted(episodes, key=lambda x: x.number)) + + def get_tracks(self, title: Title_T) -> Tracks: + response = self._api_get( + self.config["endpoints"]["vod"].format(vod_id=title.id), + params={"includePlaybackDetails": "URL"} + ) + vod = response.json() + + playback_url = vod.get("playerUrlCallback") + if not playback_url: raise ValueError("No playback URL.") + + r_play = self._api_get(playback_url) + stream_data = r_play.json() + + dash = stream_data.get("dash", []) + if not dash: raise ValueError("No DASH stream.") + entry = dash[0] + + tracks = DASH.from_url(entry["url"], session=self.session).to_tracks(language=Language.get("en")) + + if tracks.audio: + english_audio = tracks.audio[0] + + from copy import deepcopy + japanese_audio = deepcopy(english_audio) + japanese_audio.name = "Japanese" + japanese_audio.language = Language.get("ja") + + tracks.audio = [english_audio, japanese_audio] + + subtitles = [] + for sub in entry.get("subtitles", []): + fmt = sub.get("format", "").lower() + if fmt == "scc": continue + + lang_code = sub.get("language", "und").replace("-", "_") + try: lang = Language.get(lang_code) + except: lang = Language.get("und") + + url = sub.get("url", "").strip() + if not url: continue + + codec = Subtitle.Codec.WebVTT if fmt == "vtt" else Subtitle.Codec.SubRip + + try: name = lang.language_name() + except: name = lang_code + + subtitles.append( + Subtitle( + id_=f"{lang_code}:{fmt}", url=url, language=lang, + is_original_lang=lang.language == "ja", codec=codec, name=name, + forced=False, sdh=False + ) + ) + tracks.subtitles = subtitles + + # DRM: Store info for license calls + drm_data = entry.get("drm", {}) + jwt = drm_data.get("jwtToken") + lic_url = drm_data.get("url", "").strip() + if jwt and lic_url: + self._drm_cache[title.id] = (jwt, lic_url) + + return tracks + + def _hidive_get_drm_info(self, title: Title_T) -> tuple[str, str]: + if title.id in self._drm_cache: + return self._drm_cache[title.id] + self.get_tracks(title) # This will populate the cache + return self._drm_cache[title.id] + + def _decode_hidive_license_payload(self, payload: bytes) -> bytes: + text = payload.decode("utf-8", errors="ignore") + prefix = "data:application/octet-stream;base64," + if text.startswith(prefix): + b64 = text.split(",", 1)[1] + return base64.b64decode(b64) + return payload + + def get_widevine_license(self, *, challenge: bytes, title: Title_T, track: AnyTrack) -> bytes | str | None: + jwt_token, license_url = self._hidive_get_drm_info(title) + headers = { + "Authorization": f"Bearer {jwt_token}", + "Content-Type": "application/octet-stream", "Accept": "*/*", + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36", + "Origin": "https://www.hidive.com", "Referer": "https://www.hidive.com/", + "X-DRM-INFO": "eyJzeXN0ZW0iOiJjb20ud2lkZXZpbmUuYWxwaGEifQ==" + } + r = self.session.post(license_url, data=challenge, headers=headers, timeout=30) + r.raise_for_status() + return self._decode_hidive_license_payload(r.content) + + def get_chapters(self, title: Title_T) -> list[Chapter]: + return [] diff --git a/HIDE/config.yaml b/HIDE/config.yaml new file mode 100644 index 0000000..4c0a2aa --- /dev/null +++ b/HIDE/config.yaml @@ -0,0 +1,10 @@ +x_api_key: "857a1e5d-e35e-4fdf-805b-a87b6f8364bf" +x_app_var: "6.59.1.e16cdfd" + +endpoints: + init: "https://dce-frontoffice.imggaming.com/api/v1/init/" + login: "https://dce-frontoffice.imggaming.com/api/v2/login" + vod: "https://dce-frontoffice.imggaming.com/api/v4/vod/{vod_id}?includePlaybackDetails=URL" + adjacent: "https://dce-frontoffice.imggaming.com/api/v4/vod/{vod_id}/adjacent" + season_view: "https://dce-frontoffice.imggaming.com/api/v1/view" + refresh: "https://dce-frontoffice.imggaming.com/api/v2/token/refresh" \ No newline at end of file diff --git a/KOWP/__init__.py b/KOWP/__init__.py index 2d1225e..b599a6b 100644 --- a/KOWP/__init__.py +++ b/KOWP/__init__.py @@ -42,19 +42,14 @@ class KOWP(Service): def __init__(self, ctx, title: str, extras: bool = False): super().__init__(ctx) - if not self.config: - raise EnvironmentError("Missing KOWP config") - match = re.match(self.TITLE_RE, title) if not match: raise ValueError("Invalid Kocowa title ID or URL") self.title_id = match.group("title_id") self.include_extras = extras - - # Load Brightcove config - bc_conf = self.config.get("brightcove", {}) - self.brightcove_account_id = bc_conf.get("account_id", "6154734805001") - self.brightcove_pk = bc_conf.get("policy_key", "BCpkADawqM1FKrSBim1gusdFR73Prfums__ZmQ7uJ4yCRqv-RKrq2HtZIkVOn4gsmPdAqO007VNtaKJCmg0Uu1rpuUnjnP-f9OPklQ9l2-HS_F_sJXgT96KpUahg9XNukAraAlob6XDuoecD") + self.brightcove_account_id = None + self.brightcove_pk = None + self.cdm = ctx.obj.cdm def authenticate(self, cookies: Optional[CookieJar] = None, credential: Optional[Credential] = None) -> None: if not credential: @@ -90,6 +85,35 @@ class KOWP(Service): r.raise_for_status() self.middleware_token = r.json()["token"] + self._fetch_brightcove_config() + + def _fetch_brightcove_config(self): + """Fetch Brightcove account_id and policy_key from Kocowa's public config endpoint.""" + try: + r = self.session.get( + "https://middleware.bcmw.kocowa.com/api/config", + headers={ + "Origin": "https://www.kocowa.com", + "Referer": "https://www.kocowa.com/", + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36 Edg/142.0.0.0" + } + ) + r.raise_for_status() + config = r.json() + + self.brightcove_account_id = config.get("VC_ACCOUNT_ID") + self.brightcove_pk = config.get("BCOV_POLICY_KEY") + + if not self.brightcove_account_id: + raise ValueError("VC_ACCOUNT_ID missing in /api/config response") + if not self.brightcove_pk: + raise ValueError("BCOV_POLICY_KEY missing in /api/config response") + + self.log.info(f"Brightcove config loaded: account_id={self.brightcove_account_id}") + + except Exception as e: + raise RuntimeError(f"Failed to fetch or parse Brightcove config: {e}") + def get_titles(self) -> Titles_T: all_episodes = [] offset = 0 diff --git a/KOWP/config.yaml b/KOWP/config.yaml index 7d72041..734f68d 100644 --- a/KOWP/config.yaml +++ b/KOWP/config.yaml @@ -1,7 +1,3 @@ -brightcove: - account_id: "6154734805001" - policy_key: "BCpkADawqM1FKrSBim1gusdFR73Prfums__ZmQ7uJ4yCRqv-RKrq2HtZIkVOn4gsmPdAqO007VNtaKJCmg0Uu1rpuUnjnP-f9OPklQ9l2-HS_F_sJXgT96KpUahg9XNukAraAlob6XDuoecD" - endpoints: login: "https://prod-sgwv3.kocowa.com/api/v01/user/signin" middleware_auth: "https://middleware.bcmw.kocowa.com/authenticate-user" diff --git a/README.md b/README.md index a75f1df..f934bde 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,27 @@ -These services is new and in development. Please feel free to submit pull requests for any mistakes or suggestions. + +These services is new and in development. Please feel free to submit pull requests or issue a ticket for any mistakes or suggestions. +- Roadmap: + + - NPO: + - To add search functionality + - More accurate metadata (the year of showing is not according the year of release) + - Have a automatic CDM recognition option instead of the user puts it manually in the config for drmType + - KOWP: + - Fixing titles that are weird sometimes for example: folder would be named `Unknown.S01.1080p.KOWP.WEB-DL.AAC2.0.H.264` and the file would be `Unknown.S01E01.Episode.Name.Episode.1.1080p.KOWP.WEB-DL.AAC2.0.H.264.mkv` + - To add Playready Support + - Search functionality too maybe + - Cleaning the subtitles from the SDH format + - PTHS + - To add Playready Support (is needed since L3 is just 480p) + - Search Functionality + - Account login if possible + - HIDE + - Movie support + - Messed up audio (couldn't add japanese / english as secondary track audio) (needs to be fixed) + - Subtitle is a bit misplace if second sentences came up making the last sentence on the first order and vice versa (needs to be fixed + - Acknowledgment - Thanks to Adef for the NPO start downloader. +