Updated KOWP to fetch brightcove config, Added HIDE, Updated readme

This commit is contained in:
FairTrade 2025-11-05 19:48:35 +01:00
parent f77da3b134
commit 28e5dcf395
5 changed files with 301 additions and 14 deletions

236
HIDE/__init__.py Normal file
View File

@ -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<season_id>\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 []

10
HIDE/config.yaml Normal file
View File

@ -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"

View File

@ -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

View File

@ -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"

View File

@ -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.