forked from FairTrade/unshackle-services
Updated KOWP to fetch brightcove config, Added HIDE, Updated readme
This commit is contained in:
parent
f77da3b134
commit
28e5dcf395
236
HIDE/__init__.py
Normal file
236
HIDE/__init__.py
Normal 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
10
HIDE/config.yaml
Normal 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"
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
|
||||
25
README.md
25
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.
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user