Compare commits

...

14 Commits
main ... main

22 changed files with 4160 additions and 402 deletions

566
CPY/__init__.py Normal file
View File

@ -0,0 +1,566 @@
import base64
import json
import re
import time
from collections.abc import Generator
from typing import Optional, Union
import click
import requests as req_lib
from langcodes import Language
from unshackle.core.constants import AnyTrack
from unshackle.core.credential import Credential
from unshackle.core.manifests import DASH
from unshackle.core.search_result import SearchResult
from unshackle.core.service import Service
from unshackle.core.titles import Episode, Movie, Movies, Series, Title_T, Titles_T
from unshackle.core.tracks import Chapter, Tracks, Video
class CPY(Service):
"""
Service code for catchplay.com
Version: 1.0.0
Authorization: Credentials
Security: HD@L3
Use full URL (for example - https://www.catchplay.com/id/video/1b8c1ba3-9015-4f99-8131-25dd45a4b033)
or title ID (for example - 1b8c1ba3-9015-4f99-8131-25dd45a4b033).
IMPORTANT:
CHANGE YOUR PARENTAL PIN IN THE CONFIG.YAML THAT ACCORDING TO YOURS
"""
TITLE_RE = r"^(?:https?://(?:www\.)?catchplay\.com/\w+/(?:movie|series|video)/)?(?P<title_id>[a-f0-9-]{36})"
GEOFENCE = ("ID", "TW", "SG", "HK", "TH")
@staticmethod
@click.command(name="CPY", short_help="https://catchplay.com")
@click.argument("title", type=str)
@click.pass_context
def cli(ctx, **kwargs):
return CPY(ctx, **kwargs)
def __init__(self, ctx, title):
super().__init__(ctx)
self.title = title
self.cdm = ctx.obj.cdm
self.access_token: Optional[str] = None
self.refresh_token: Optional[str] = None
self.token_expiry: float = 0
self.account_info: dict = {}
self.play_token: Optional[str] = None
self.license_url: Optional[str] = None
self.license_headers: Optional[dict] = None
profile_name = ctx.parent.params.get("profile")
self.profile = profile_name or "default"
def authenticate(self, cookies=None, credential: Optional[Credential] = None) -> None:
super().authenticate(cookies, credential)
if not credential or not credential.username or not credential.password:
raise EnvironmentError("Service requires Credentials for Authentication.")
self.credential = credential
cache_key = f"tokens_{self.profile}"
cache = self.cache.get(cache_key)
if cache and not cache.expired:
cached = cache.data
if isinstance(cached, dict) and cached.get("username") == credential.username:
# Check if access token is still valid
if cached.get("token_expiry", 0) > time.time():
self.log.info("Using cached tokens")
self._restore_from_cache(cached)
return
# Access token expired but we have a refresh token
elif cached.get("refresh_token"):
self.log.info("Access token expired, refreshing...")
try:
self._refresh_auth(cached["refresh_token"])
self._cache_tokens(credential.username, cache_key)
return
except Exception as e:
self.log.warning(f"Refresh failed ({e}), doing fresh login...")
# Fresh login
self.log.info("Logging in...")
self._do_login(credential)
self._cache_tokens(credential.username, cache_key)
def _do_login(self, credential: Credential) -> None:
"""Perform full guest token + credential login flow."""
territory = self.config.get("territory", "ID")
device = self.config["device"]
# Step 1: Guest token
self.log.info("Fetching guest token...")
guest_resp = self.session.get(
url=self.config["endpoints"]["guest_token"],
headers={"Referer": f"https://www.catchplay.com/{territory.lower()}/home"},
).json()
if not guest_resp.get("access_token"):
raise Exception("Failed to get guest token")
# Step 2: Login
login_resp = self.session.post(
url=self.config["endpoints"]["login"],
headers={
"Content-Type": "application/json",
"asiaplay-territory": territory,
"asiaplay-device-type": device["type"],
"asiaplay-device-version": device["version"],
"Referer": f"https://www.catchplay.com/{territory.lower()}/login",
},
json={
"username": credential.username,
"password": credential.password,
"rememberMe": False,
},
).json()
if not login_resp.get("access_token"):
raise Exception(f"Login failed: {login_resp}")
self._apply_tokens(login_resp)
user = login_resp.get("user", {})
self.account_info = user
self.log.info(
f" + Logged in as: {credential.username} "
f"[{user.get('accountType', 'unknown')} / {user.get('accountStatus', 'unknown')}]"
)
def _refresh_auth(self, refresh_token: str) -> None:
"""Refresh the access token using the refresh token."""
refresh_resp = self.session.get(
url=self.config["endpoints"]["refresh"],
headers={
"Referer": "https://www.catchplay.com/",
},
cookies={"connect.sid": self._connect_sid} if hasattr(self, "_connect_sid") else {},
).json()
if not refresh_resp.get("access_token"):
raise Exception(f"Refresh failed: {refresh_resp}")
self._apply_tokens(refresh_resp)
user = refresh_resp.get("user", {})
self.account_info = user
self.log.info(
f" + Token refreshed "
f"[{user.get('accountType', 'unknown')} / {user.get('accountStatus', 'unknown')}]"
)
def _apply_tokens(self, token_data: dict) -> None:
"""Apply tokens from login or refresh response to session."""
self.access_token = token_data["access_token"]
self.refresh_token = token_data.get("refresh_token")
# Calculate expiry from JWT or expires_in
expires_in = token_data.get("expires_in", 86400)
self.token_expiry = time.time() + expires_in - 300 # 5 min buffer
territory = self.config.get("territory", "ID")
device = self.config["device"]
self.session.headers.update({
"authorization": f"Bearer {self.access_token}",
"asiaplay-territory": territory,
"asiaplay-device-type": device["type"],
"asiaplay-device-version": device["version"],
"asiaplay-os-type": device["os_type"],
"asiaplay-os-version": device["os_version"],
"origin": "https://www.catchplay.com",
"referer": "https://www.catchplay.com/",
})
def _cache_tokens(self, username: str, cache_key: str) -> None:
"""Cache current tokens for reuse."""
cache = self.cache.get(cache_key)
cache.set(
data={
"username": username,
"access_token": self.access_token,
"refresh_token": self.refresh_token,
"token_expiry": self.token_expiry,
"account_info": self.account_info,
}
)
def _restore_from_cache(self, cached: dict) -> None:
"""Restore session state from cached token data."""
self.access_token = cached["access_token"]
self.refresh_token = cached.get("refresh_token")
self.token_expiry = cached.get("token_expiry", 0)
self.account_info = cached.get("account_info", {})
territory = self.config.get("territory", "ID")
device = self.config["device"]
self.session.headers.update({
"authorization": f"Bearer {self.access_token}",
"asiaplay-territory": territory,
"asiaplay-device-type": device["type"],
"asiaplay-device-version": device["version"],
"asiaplay-os-type": device["os_type"],
"asiaplay-os-version": device["os_version"],
"origin": "https://www.catchplay.com",
"referer": "https://www.catchplay.com/",
})
self.log.info(
f" + Restored session "
f"[{self.account_info.get('accountType', 'unknown')} / "
f"{self.account_info.get('accountStatus', 'unknown')}]"
)
def _graphql(self, key: str, variables: dict) -> dict:
"""Execute a GraphQL query defined in config."""
cfg = self.config["graphql"][key]
endpoint_key = cfg["endpoint"]
url = self.config["endpoints"][endpoint_key]
resp = self.session.post(
url=url,
headers={
"asiaplay-api-name": cfg["api_name"],
"content-type": "application/json",
},
json={
"operationName": cfg["operation"],
"variables": variables,
"query": cfg["query"],
},
).json()
if resp.get("errors"):
raise Exception(f"GraphQL error ({key}): {resp['errors']}")
return resp["data"]
def search(self) -> Generator[SearchResult, None, None]:
self.log.info(f"Searching for: {self.title}")
data = self._graphql("search", {"keyword": self.title})
programs = data.get("searchKeywordSuggestions", {}).get("programs", [])
for program in programs:
yield SearchResult(
id_=program["id"],
title=program["name"],
label="TITLE",
url=f"https://www.catchplay.com/id/video/{program['id']}",
)
@staticmethod
def _title_from(obj: dict) -> str:
return obj.get("title", {}).get("eng") or obj.get("title", {}).get("local") or "Unknown"
@staticmethod
def _extract_season_number(title: str) -> int:
match = re.search(r"S(\d+)", title)
return int(match.group(1)) if match else 1
@staticmethod
def _extract_episode_number(title: str) -> int:
match = re.search(r"Episode\s+(\d+)", title, re.IGNORECASE)
return int(match.group(1)) if match else 0
def get_titles(self) -> Titles_T:
title_id = re.match(self.TITLE_RE, self.title)
if not title_id:
raise ValueError(f"Could not parse title ID from: {self.title}")
self.title = title_id.group("title_id")
self.log.info(f"Fetching metadata for: {self.title}")
main = self._graphql("get_main_program", {"id": self.title})["getMainProgram"]
program_type = main.get("type", "MOVIE")
series_title = self._title_from(main)
selected = main.get("selected", {})
release_year = selected.get("releaseYear")
lang = Language.get(self.config.get("default_language", "en"))
if program_type == "MOVIE":
title_name = self._title_from(selected) if selected else series_title
program_meta = self._graphql("get_program", {"id": self.title})["getProgram"]
return Movies([
Movie(
id_=self.title,
service=self.__class__,
name=title_name,
year=release_year,
language=lang,
data={"videoIntros": program_meta.get("videoIntros", {})},
)
])
elif program_type in ("SERIES", "SEASON"):
episodes = []
children = main.get("children", [])
for season_data in children:
if season_data.get("type") == "SEASON":
season_short = season_data.get("title", {}).get("short", "S1")
season_num = self._extract_season_number(season_short)
selected_children = (
selected.get("children", [])
if selected.get("id") == season_data["id"]
else []
)
selected_map = {ep["id"]: ep for ep in selected_children}
for idx, ep_data in enumerate(season_data.get("children", []), start=1):
ep_id = ep_data["id"]
ep_detail = selected_map.get(ep_id, {})
ep_title = (
self._title_from(ep_detail) if ep_detail.get("title") else
self._title_from(ep_data) if ep_data.get("title") else
f"Episode {idx}"
)
ep_num = self._extract_episode_number(ep_title) or idx
episodes.append(Episode(
id_=ep_id,
service=self.__class__,
title=series_title,
season=season_num,
number=ep_num,
name=ep_title,
year=release_year,
language=lang,
data={"season_id": season_data["id"]},
))
elif season_data.get("type") == "EPISODE":
ep_title = self._title_from(season_data)
ep_num = self._extract_episode_number(ep_title) or len(episodes) + 1
episodes.append(Episode(
id_=season_data["id"],
service=self.__class__,
title=series_title,
season=1,
number=ep_num,
name=ep_title,
year=release_year,
language=lang,
data={},
))
if not episodes:
raise Exception(f"No episodes found for: {series_title}")
self.log.info(f" + Found {len(episodes)} episodes across {len(children)} season(s)")
return Series(episodes)
else:
raise NotImplementedError(f"Unsupported program type: {program_type}")
def get_tracks(self, title: Title_T) -> Tracks:
is_episode = isinstance(title, Episode)
# Play scenario
self.log.info("Checking play scenario...")
scenario = self._graphql(
"get_play_scenario",
{"input": {"programId": title.id}}
)["getPlayScenario"]
behavior = scenario.get("behaviorType")
self.log.info(f" + Play scenario: {behavior}")
if behavior != "PLAYABLE":
reason = scenario.get("reason", {})
raise Exception(
f"Not playable. Behavior: {behavior}. "
f"Reason: {reason.get('message', 'Unknown')}"
)
# Parental control
parental = scenario.get("parentalControl", {})
if parental and parental.get("behaviorType") == "PIN_CODE_REQUIRED":
self.log.info("Validating parental PIN...")
pin = self.config.get("parental_pin", "0000")
pin_result = self._graphql(
"validate_pin",
{"input": {"pinCode": pin}}
)["validateParentalControlPinCode"]
if pin_result.get("status") != "SUCCESSFUL":
raise Exception(f"PIN validation failed: {pin_result.get('status')}")
self.log.info(" + PIN validated")
# Play token
self.log.info("Getting play token...")
play_resp = self.session.post(
url=self.config["endpoints"]["play"],
headers={"content-type": "application/json"},
json={
"force": False,
"programType": "Video",
"videoId": title.id,
"watchType": "episode" if is_episode else "movie",
},
).json()
if play_resp.get("code") != "0":
raise Exception(f"Play token failed: {play_resp}")
play_data = play_resp["data"]
self.play_token = play_data.get("vcmsAccessToken") or play_data.get("playToken")
video_id = play_data.get("catchplayVideoId")
if not self.play_token or not video_id:
raise Exception("Missing play token or video ID")
self.log.info(f" + Play token for: {video_id}")
# Media info
self.log.info("Fetching media info...")
vcms = self.config["vcms"]
media_resp = self.session.get(
url=self.config["endpoints"]["media_info"].format(video_id=video_id),
headers={
"authorization": f"Bearer {self.play_token}",
"asiaplay-device-type": vcms["device_type"],
"asiaplay-device-model": vcms["device_model"],
"asiaplay-os-type": vcms["os_type"],
"asiaplay-os-version": vcms["os_version"],
"asiaplay-app-version": vcms["app_version"],
"asiaplay-platform": vcms["platform"],
"content-type": "application/x-www-form-urlencoded",
},
).json()
manifest_url = media_resp.get("videoUrl")
if not manifest_url:
raise Exception(f"No video URL: {media_resp}")
self.log.debug(f"Manifest: {manifest_url}")
# DRM
license_info = media_resp.get("license", {})
self.license_url = license_info.get("url", self.config["endpoints"]["widevine_license"])
self.license_headers = license_info.get("extraHeaders", {})
# DASH manifest (clean CDN session)
self.log.info("Parsing DASH manifest...")
cdn_session = req_lib.Session()
cdn_session.headers.update({
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36 Edg/147.0.0.0"
),
"Accept": "*/*",
"Accept-Language": "en-US,en;q=0.9",
"Origin": "https://www.catchplay.com",
"Referer": "https://www.catchplay.com/",
"Sec-Fetch-Dest": "empty",
"Sec-Fetch-Mode": "cors",
"Sec-Fetch-Site": "cross-site",
})
tracks = DASH.from_url(url=manifest_url, session=cdn_session).to_tracks(language=title.language)
for video in tracks.videos:
video.range = Video.Range.SDR
# VideoIntros for chapters
if is_episode:
meta = self._graphql("get_program", {"id": title.id})["getProgram"]
title.data["videoIntros"] = meta.get("videoIntros", {})
elif not title.data.get("videoIntros"):
meta = self._graphql("get_program", {"id": title.id})["getProgram"]
title.data["videoIntros"] = meta.get("videoIntros", {})
return tracks
def get_chapters(self, title: Title_T) -> list[Chapter]:
chapters = []
intros = title.data.get("videoIntros", {})
if not intros:
return chapters
def to_ms(iso: str) -> Optional[int]:
if not iso:
return None
m = re.match(r"PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?", iso)
if not m:
return None
h, mi, s = int(m.group(1) or 0), int(m.group(2) or 0), int(m.group(3) or 0)
return (h * 3600 + mi * 60 + s) * 1000
if intros.get("intro"):
start = to_ms(intros["intro"].get("startTime"))
end = to_ms(intros["intro"].get("endTime"))
if start is not None:
chapters.append(Chapter(timestamp=start, name="Intro"))
if end is not None:
chapters.append(Chapter(timestamp=end, name="After Intro"))
if intros.get("recap"):
start = to_ms(intros["recap"].get("startTime"))
end = to_ms(intros["recap"].get("endTime"))
if start is not None:
chapters.append(Chapter(timestamp=start, name="Recap"))
if end is not None:
chapters.append(Chapter(timestamp=end, name="After Recap"))
if intros.get("credits"):
start = to_ms(intros["credits"].get("startTime"))
if start is not None:
chapters.append(Chapter(timestamp=start, name="Credits"))
chapters.sort(key=lambda c: c.timestamp)
return chapters
def get_widevine_service_certificate(self, **_) -> Optional[str]:
return self.config.get("certificate")
def get_widevine_license(self, *, challenge: bytes, title: Title_T, track: AnyTrack) -> Optional[Union[bytes, str]]:
if not self.license_url:
raise ValueError("No license URL. Call get_tracks() first.")
license_session = req_lib.Session()
license_session.headers.update({
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36 Edg/147.0.0.0"
),
"Accept": "*/*",
"Accept-Language": "en-US,en;q=0.9",
"Origin": "https://www.catchplay.com",
"Referer": "https://www.catchplay.com/",
"Sec-Fetch-Dest": "empty",
"Sec-Fetch-Mode": "cors",
"Sec-Fetch-Site": "same-site",
})
license_session.headers.update(self.license_headers)
response = license_session.post(url=self.license_url, data=challenge)
if not response.ok:
self.log.error(f"License error: {response.text}")
response.raise_for_status()
try:
return response.json().get("license")
except ValueError:
return response.content

136
CPY/config.yaml Normal file
View File

@ -0,0 +1,136 @@
territory: ID
default_language: en
parental_pin: "0000" #CHANGE THIS ACCORDING TO YOUR PIN
device:
type: WEB_PC
version: 3.0.138.4463
os_type: Windows_Edge
os_version: "10,146.0.0.0"
vcms:
device_type: web
device_model: windows
os_type: chrome
os_version: 147.0.0
app_version: "3.0"
platform: desktop
endpoints:
guest_token: https://www.catchplay.com/api/v2/oauth
login: https://www.catchplay.com/api/v2/oauth/login
refresh: https://www.catchplay.com/api/v2/oauth/refresh
graphql_program: https://sunapi.catchplay.com/program/v3/graphql
graphql_membership: https://sunapi.catchplay.com/membership/v3/graphql
graphql_membership_program: https://sunapi.catchplay.com/membership-program/v3/graphql
play: https://hp2-api.catchplay.com/me/play
media_info: "https://vcmsapi.catchplay.com/video/v3/mediaInfo/{video_id}"
widevine_license: https://vcmsapi.catchplay.com/video-drm/widevine
graphql:
search:
operation: searchKeywordSuggestions
api_name: searchKeywordSuggestions
endpoint: graphql_membership_program
query: |
query searchKeywordSuggestions($keyword: String!) {
searchKeywordSuggestions(keyword: $keyword) {
programs {
id
name
photoUrl
orientation
}
}
}
get_main_program:
operation: getMainProgram
api_name: getMainProgram
endpoint: graphql_program
query: |
query getMainProgram($id: ID!) {
getMainProgram(id: $id) {
id
type
title { local eng }
totalChildren
children {
id
type
title { short local eng }
children {
id
type
title { local eng }
publishedDate
playerInfo { duration videoCode }
}
}
selected {
id
type
releaseYear
synopsis
title { local eng }
children {
id
type
title { local eng }
synopsis
publishedDate
playerInfo { duration videoCode }
}
}
}
}
get_program:
operation: getProgram
api_name: getProgram
endpoint: graphql_program
query: |
query getProgram($id: ID!) {
getProgram(id: $id) {
id
title { local eng }
type
videoIntros {
intro { startTime endTime }
recap { startTime endTime }
credits { startTime endTime }
}
}
}
get_play_scenario:
operation: getPlayScenario
api_name: getPlayScenario
endpoint: graphql_membership_program
query: |
query getPlayScenario($input: PlayScenarioInput!) {
getPlayScenario(input: $input) {
behaviorType
description
reason { message }
parentalControl { behaviorType title message }
playProgram {
id
type
title { local playing }
playerInfo { videoCode }
}
}
}
validate_pin:
operation: validateParentalControlPinCode
api_name: validateParentalControlPinCode
endpoint: graphql_membership
query: |
query validateParentalControlPinCode($input: ValidateParentalControlInput!) {
validateParentalControlPinCode(input: $input) {
status
description
}
}

471
GLA/__init__.py Normal file
View File

@ -0,0 +1,471 @@
import base64
import hashlib
import json
import re
import time
from collections.abc import Generator
from datetime import datetime
from http.cookiejar import CookieJar
from typing import Optional, Union
from urllib.parse import urljoin, parse_qs, urlparse
import click
from langcodes import Language
from unshackle.core.constants import AnyTrack
from unshackle.core.credential import Credential
from unshackle.core.manifests import DASH, HLS
from unshackle.core.search_result import SearchResult
from unshackle.core.service import Service
from unshackle.core.titles import Episode, Movie, Movies, Series, Title_T, Titles_T
from unshackle.core.tracks import Chapter, Subtitle, Tracks, Video
class GLA(Service):
"""
Service code for gagaoolala.com
Version: 1.0.1
Authorization: Email/Password or Cookies (PHPSESSID)
Security: FHD@L3 (Widevine/PlayReady DRM via ExpressPlay)
Use full URL: https://www.gagaoolala.com/en/videos/6184/candy-2026
Or title ID: 6184 (slug will be fetched from page if needed)
"""
# Updated regex to optionally capture slug
TITLE_RE = r"^(?:https?://(?:www\.)?gagaoolala\.com/(?:en/)?videos/)?(?P<title_id>\d+)(?:/(?P<slug>[^/?#]+))?"
GEOFENCE = ()
NO_SUBTITLES = False
VIDEO_RANGE_MAP = {
"SDR": "sdr",
"HDR10": "hdr10",
"DV": "dolby_vision",
}
@staticmethod
@click.command(name="GLA", short_help="https://www.gagaoolala.com")
@click.argument("title", type=str)
@click.option("-m", "--movie", is_flag=True, default=False, help="Specify if it's a movie")
@click.option("-d", "--device", type=str, default="firefox_linux", help="Select device profile")
@click.pass_context
def cli(ctx, **kwargs):
return GLA(ctx, **kwargs)
def __init__(self, ctx, title, movie, device, email=None, password=None):
super().__init__(ctx)
self.title = title
self.movie = movie
self.device = device
self.email = email
self.password = password
self.cdm = ctx.obj.cdm
# Override codec/range for L3 CDM limitations
if self.cdm and self.cdm.security_level == 3:
self.track_request.codecs = [Video.Codec.AVC]
self.track_request.ranges = [Video.Range.SDR]
if self.config is None:
raise Exception("Config is missing!")
self.profile = ctx.parent.params.get("profile") or "default"
self.user_id = None
self.license_data = {}
self.slug = None # Store slug for API calls
def authenticate(self, cookies: Optional[CookieJar] = None, credential: Optional[Credential] = None) -> None:
super().authenticate(cookies, credential)
if cookies:
self.session.cookies.update(cookies)
for cookie in cookies:
if cookie.name == "gli":
self.user_id = cookie.value
break
return
if not credential or not credential.username or not credential.password:
raise EnvironmentError("Service requires Cookies or Credential (email/password) for Authentication.")
login_url = "https://www.gagaoolala.com/en/user/login"
login_data = {
"email": credential.username,
"passwd": credential.password,
}
headers = {
"User-Agent": self.config["client"][self.device]["user_agent"],
"Accept": "application/json, text/javascript, */*; q=0.01",
"Accept-Language": "en-US,en;q=0.9",
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
"X-Requested-With": "XMLHttpRequest",
"Origin": "https://www.gagaoolala.com",
"Referer": login_url,
}
response = self.session.post(login_url, data=login_data, headers=headers)
response.raise_for_status()
result = response.json()
if not result.get("success"):
error_msg = result.get("msg") or result.get("data", {}).get("msg") or "Unknown error"
raise AuthenticationError(f"Login failed: {error_msg}")
self.user_id = result.get("data", {}).get("user_line_uid")
def search(self) -> Generator[SearchResult, None, None]:
search_url = "https://www.gagaoolala.com/en/search"
params = {"q": self.title}
headers = {
"User-Agent": self.config["client"][self.device]["user_agent"],
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.9",
"X-Requested-With": "XMLHttpRequest",
}
response = self.session.get(search_url, params=params, headers=headers)
response.raise_for_status()
content_type = response.headers.get("Content-Type", "")
if "application/json" in content_type:
try:
data = response.json()
except json.JSONDecodeError:
data = None
else:
data = None
if not data:
html = response.text
json_ld_match = re.search(
r'<script[^>]+type=["\']application/ld\+json["\'][^>]*>\s*({.*?"@context".*?})\s*</script>',
html,
re.DOTALL | re.IGNORECASE
)
if json_ld_match:
json_str = json_ld_match.group(1)
json_str = json_str.replace(r'\/', '/').replace(r'\"', '"')
try:
data = json.loads(json_str)
except json.JSONDecodeError as e:
self.log.debug(f"Failed to parse JSON-LD: {e}")
data = None
else:
fallback_match = re.search(
r'(\{[^{}]*"@context"[^{}]*"itemListElement"[^{}]*\[\s*\{[^{}]*"url"[^{}]*\][^{}]*\})',
html,
re.DOTALL
)
if fallback_match:
try:
data = json.loads(fallback_match.group(1))
except json.JSONDecodeError:
data = None
if not data or "itemListElement" not in data:
self.log.warning(f"No search results found for '{self.title}'")
return
for item in data["itemListElement"]:
url = item.get("url", "")
if not url:
continue
match = re.match(self.TITLE_RE, url)
if not match:
continue
title_id = match.group("title_id")
slug = match.group("slug")
# Extract title name from slug or URL
title_name = slug if slug else url.rstrip("/").split("/")[-1]
if "-" in title_name:
parts = title_name.rsplit("-", 1)
# Remove year suffix if present (e.g., candy-2026 -> candy)
if parts[-1].isdigit() and len(parts[-1]) == 4:
title_name = parts[0]
title_name = title_name.replace("-", " ").title()
# Detect series vs movie
is_series = bool(slug and ("-e" in slug or slug.endswith("-e01")))
yield SearchResult(
id_=title_id,
title=title_name,
label="SERIES" if is_series else "MOVIE",
url=url,
)
def _clean_title(self, raw_title: str, slug: Optional[str] = None) -> str:
"""Clean up page titles by removing SEO/marketing suffixes."""
title = re.sub(r'\s*\|\s*GagaOOLala\s*$', '', raw_title).strip()
seo_patterns = [
r'\s*-\s*Watch\s+Online.*$',
r'\s*-\s*Find\s+Your\s+Story.*$',
r'\s*-\s*Watch\s+BL\s+Movies.*$',
r'\s*-\s*Stream\s+Online.*$',
r'\s*-\s*Free\s+Streaming.*$',
r'\s*-\s*GagaOOLala.*$',
]
for pattern in seo_patterns:
title = re.sub(pattern, '', title, flags=re.IGNORECASE)
title = re.sub(r'\s*-\s*$', '', title).strip()
if slug:
slug_title = slug.replace('-', ' ').title()
year_match = re.search(r'(\d{4})$', slug)
if year_match:
year = year_match.group(1)
slug_title = re.sub(r'\s*\d{4}\s*$', '', slug_title).strip()
candidate = f"{slug_title} ({year})"
if len(candidate) < len(title) or title.lower().startswith(slug_title.lower()):
return candidate
return title if title else f"Title {self.title}"
def get_titles(self) -> Titles_T:
match = re.match(self.TITLE_RE, self.title)
if not match:
raise ValueError(f"Could not parse title ID from: {self.title}")
title_id = match.group("title_id")
self.slug = match.group("slug")
video_url = f"https://www.gagaoolala.com/en/videos/{title_id}"
if self.slug:
video_url += f"/{self.slug}"
response = self.session.get(video_url)
if response.status_code == 404 and self.slug:
self.log.warning(f"URL with slug returned 404, trying without slug")
video_url = f"https://www.gagaoolala.com/en/videos/{title_id}"
response = self.session.get(video_url)
response.raise_for_status()
episodes_match = re.search(r'var\s+videoEpisodes\s*=\s*(\[.*?\]);\s*var\s+videoSeasons', response.text, re.DOTALL)
if episodes_match:
episodes_data = json.loads(episodes_match.group(1))
series_episodes = [ep for ep in episodes_data if ep.get("is_series")]
if series_episodes:
first_name = series_episodes[0].get("name", "")
base_title = re.sub(r'\s*Episode\s*\d+.*$', '', first_name).strip()
if not base_title and self.slug:
base_title = self._clean_title(self.slug.replace('-', ' ').title(), None)
if not base_title:
base_title = f"Series {title_id}"
episodes = []
for ep in series_episodes:
ep_slug = ep.get("slug", f"{self.slug}-e{ep.get('episode', 1)}" if self.slug else None)
episodes.append(
Episode(
id_=str(ep["id"]),
service=self.__class__,
title=base_title,
season=ep.get("season", 1),
number=ep.get("episode", 1),
name=ep.get("name", f"Episode {ep.get('episode', 1)}"),
description=None,
year=None,
language=Language.get("en"),
data={**ep, "slug": ep_slug, "parent_slug": self.slug},
)
)
return Series(episodes)
title_match = re.search(r'<title>([^<]+)</title>', response.text)
raw_title = title_match.group(1) if title_match else (self.slug or f"Movie {title_id}")
movie_title = self._clean_title(raw_title, self.slug)
year = None
year_match = re.search(r'\((\d{4})\)\s*$', movie_title)
if year_match:
year = int(year_match.group(1))
movie_title = re.sub(r'\s*\(\d{4}\)\s*$', '', movie_title).strip()
elif self.slug:
slug_year = re.search(r'(\d{4})$', self.slug)
if slug_year:
year = int(slug_year.group(1))
return Movies(
[
Movie(
id_=title_id,
service=self.__class__,
name=movie_title,
description=None,
year=year,
language=Language.get("en"),
data={"url": video_url, "slug": self.slug},
)
]
)
def get_tracks(self, title: Title_T) -> Tracks:
def _fetch_variant(
title: Title_T,
codec: Optional[Video.Codec],
range_: Video.Range,
) -> Tracks:
vcodec_str = "H265" if codec == Video.Codec.HEVC else "H264"
range_str = range_.name
video_format = self.VIDEO_RANGE_MAP.get(range_str, "sdr")
tracks = self._fetch_manifest(title)
if codec:
tracks.videos = [v for v in tracks.videos if v.codec == codec]
if range_ != Video.Range.SDR:
tracks.videos = [v for v in tracks.videos if v.range == range_]
if not tracks.videos:
raise ValueError(f"No tracks available for {codec} {range_}")
return tracks
tracks = self._get_tracks_for_variants(title, _fetch_variant)
return tracks
def get_tracks(self, title: Title_T) -> Tracks:
def _fetch_variant(
title: Title_T,
codec: Optional[Video.Codec],
range_: Video.Range,
) -> Tracks:
vcodec_str = "H265" if codec == Video.Codec.HEVC else "H264"
range_str = range_.name
video_format = self.VIDEO_RANGE_MAP.get(range_str, "sdr")
tracks = self._fetch_manifest(title)
if codec:
tracks.videos = [v for v in tracks.videos if v.codec == codec]
if range_ != Video.Range.SDR:
tracks.videos = [v for v in tracks.videos if v.range == range_]
if not tracks.videos:
raise ValueError(f"No tracks available for {codec} {range_}")
return tracks
return self._get_tracks_for_variants(title, _fetch_variant)
def _fetch_manifest(self, title: Title_T) -> Tracks:
timestamp = int(time.time())
slug = title.data.get("slug") if isinstance(title.data, dict) else None
if not slug:
slug = title.data.get("parent_slug") if isinstance(title.data, dict) else self.slug
if not slug:
match = re.match(self.TITLE_RE, self.title)
if match:
slug = match.group("slug")
if slug:
play_url = f"https://www.gagaoolala.com/api/v1.0/en/videos/{title.id}/{slug}/play"
else:
play_url = f"https://www.gagaoolala.com/api/v1.0/en/videos/{title.id}/play"
self.log.warning(f"No slug available, attempting play request without slug: {play_url}")
params = {"t": timestamp}
response = self.session.get(play_url, params=params)
response.raise_for_status()
playback = response.json()
if not playback.get("success"):
raise ValueError(f"Failed to get playback info: {playback}")
data = playback["data"]
drm_info = data.get("drm")
if drm_info:
self.license_data = {
"widevine": drm_info.get("widevine", {}).get("LA_URL"),
"playready": drm_info.get("playready", {}).get("LA_URL"),
}
else:
self.license_data = {}
manifest_url = data.get("dash") or data.get("m3u8")
if not manifest_url:
raise ValueError("No manifest URL found in playback response")
if ".mpd" in manifest_url:
tracks = DASH.from_url(url=manifest_url, session=self.session).to_tracks(language=title.language)
elif ".m3u8" in manifest_url:
tracks = HLS.from_url(url=manifest_url, session=self.session).to_tracks(language=title.language)
else:
raise ValueError(f"Unsupported manifest format: {manifest_url}")
for video in tracks.videos:
if video.codec == Video.Codec.HEVC and video.profile and "Main10" in str(video.profile):
video.range = Video.Range.HDR10
else:
video.range = Video.Range.SDR
return tracks
def get_chapters(self, title: Title_T) -> list[Chapter]:
return []
def get_widevine_service_certificate(self, **_: any) -> str:
return self.config.get("certificate", "")
def get_widevine_license(self, *, challenge: bytes, title: Title_T, track: AnyTrack) -> Optional[Union[bytes, str]]:
if not self.license_data.get("widevine"):
raise ValueError("Widevine license URL not available for this title")
license_url = self.license_data["widevine"]
headers = {
"User-Agent": self.config["client"][self.device].get("license_user_agent",
self.config["client"][self.device]["user_agent"]),
"Content-Type": "application/octet-stream",
}
response = self.session.post(
url=license_url,
data=challenge,
headers=headers,
)
response.raise_for_status()
return response.content
def get_playready_license(self, *, challenge: bytes, title: Title_T, track: AnyTrack) -> Optional[Union[bytes, str]]:
if not self.license_data.get("playready"):
raise ValueError("PlayReady license URL not available for this title")
license_url = self.license_data["playready"]
headers = {
"User-Agent": self.config["client"][self.device].get("license_user_agent",
self.config["client"][self.device]["user_agent"]),
"Content-Type": "text/xml",
"SOAPAction": "http://schemas.microsoft.com/DRM/2007/03/protocols/AcquireLicense",
}
response = self.session.post(
url=license_url,
data=challenge,
headers=headers,
)
response.raise_for_status()
return response.content

16
GLA/config.yaml Normal file
View File

@ -0,0 +1,16 @@
# config.yaml for GLA (GagaOOLala)
endpoints:
login: https://www.gagaoolala.com/en/user/login
play: https://www.gagaoolala.com/api/v1.0/en/videos/{title_id}/{slug}/play
search: https://www.gagaoolala.com/en/search
client:
firefox_linux:
user_agent: "Mozilla/5.0 (X11; Linux x86_64; rv:149.0) Gecko/20100101 Firefox/149.0"
license_user_agent: "Mozilla/5.0 (X11; Linux x86_64; rv:149.0) Gecko/20100101 Firefox/149.0"
android_tv:
user_agent: "Mozilla/5.0 (Linux; Android 10; Android TV) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.120 Safari/537.36"
license_user_agent: "ExoPlayerLib/2.18.1"
windows_chrome:
user_agent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
license_user_agent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"

View File

@ -1,31 +1,37 @@
import json
import re
import base64
import hashlib
import click
from http.cookiejar import CookieJar
from typing import Optional, Iterable
from langcodes import Language
import base64
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, Movie, Movies, Title_T, Titles_T
from unshackle.core.tracks import Chapter, Tracks, Subtitle, Audio
from unshackle.core.tracks import Chapter, Tracks, Subtitle, Audio, Video
from unshackle.core.utilities import import_module_by_path
class HIDI(Service):
"""
Service code for HiDive (hidive.com)
Version: 1.2.0
Version: 1.3.2
Authorization: Email + password login, with automatic token refresh.
Security: FHD@L3
IMPORTANT: UPDATE YOUR UNSHACKLE TO 2.3.0 TO GET THE NECESSARY FIX FOR THIS SERVICE
Also when downloading a series, use the link from the first season of the series
"""
TITLE_RE = r"^https?://(?:www\.)?hidive\.com/(?:season/(?P<season_id>\d+)|playlist/(?P<playlist_id>\d+))$"
GEOFENCE = ()
NO_SUBTITLES = False
API_BASE = "https://dce-frontoffice.imggaming.com/api/v4"
@staticmethod
@click.command(name="HIDI", short_help="https://hidive.com")
@ -110,36 +116,160 @@ class HIDI(Service):
resp.raise_for_status()
return resp
def get_titles(self) -> Titles_T:
# One endpoint for both season and playlist
resp = self._api_get(
def _fetch_season_data(self, season_id: int) -> dict:
"""Fetch season view data."""
return self._api_get(
self.config["endpoints"]["view"],
params={"type": ("playlist" if self.kind == "movie" else "season"),
"id": self.content_id,
"timezone": "Europe/Amsterdam"}
)
data = resp.json()
params={
"type": "season",
"id": season_id,
"timezone": "Europe/Amsterdam"
}
).json()
def _fetch_adjacent_seasons(self, series_id: int, season_id: int) -> dict:
"""Fetch all seasons in a series using adjacentTo endpoint."""
url = f"{self.API_BASE}/series/{series_id}/adjacentTo/{season_id}"
return self._api_get(url, params={"size": 25}).json()
def _extract_series_info(self, season_data: dict) -> tuple[Optional[int], Optional[str]]:
"""
Extract series ID and title from season data.
Checks multiple locations in the JSON structure.
"""
series_id = None
series_title = None
# Method 1: Check metadata.series
metadata = season_data.get("metadata", {})
if metadata.get("series"):
series_id = metadata["series"].get("seriesId")
series_title = metadata["series"].get("title")
if series_id:
return series_id, series_title
# Method 2: Check elements for $type: "series"
for elem in season_data.get("elements", []):
if elem.get("$type") == "series":
attrs = elem.get("attributes", {})
series_id = attrs.get("id")
series_info = attrs.get("series", {})
series_title = series_info.get("title") or series_title
if series_id:
return series_id, series_title
# Method 3: Check bucket elements for seriesId
for elem in season_data.get("elements", []):
if elem.get("$type") == "bucket":
attrs = elem.get("attributes", {})
if attrs.get("seriesId"):
series_id = attrs["seriesId"]
return series_id, series_title
# Method 4: Check hero actions for seriesId
for elem in season_data.get("elements", []):
if elem.get("$type") == "hero":
for action in elem.get("attributes", {}).get("actions", []):
action_data = action.get("attributes", {}).get("action", {}).get("data", {})
if action_data.get("seriesId"):
series_id = action_data["seriesId"]
return series_id, series_title
return series_id, series_title
def _extract_season_number(self, season_data: dict) -> int:
"""Extract season number from season data."""
# Check metadata.currentSeason
metadata = season_data.get("metadata", {})
current_season = metadata.get("currentSeason", {})
if current_season.get("title"):
# Parse "Season 2" -> 2
title = current_season["title"]
if title.lower().startswith("season "):
try:
return int(title.split(" ")[1])
except (ValueError, IndexError):
pass
# Check elements for series type with seasons info
for elem in season_data.get("elements", []):
if elem.get("$type") == "series":
seasons_items = elem.get("attributes", {}).get("seasons", {}).get("items", [])
for item in seasons_items:
if item.get("seasonNumber"):
return item["seasonNumber"]
# Check bucket title
for elem in season_data.get("elements", []):
if elem.get("$type") == "bucket" and elem.get("attributes", {}).get("type") == "season":
bucket_title = elem.get("attributes", {}).get("bucketTitle", "")
if bucket_title.lower().startswith("season "):
try:
return int(bucket_title.split(" ")[1])
except (ValueError, IndexError):
pass
return 1
def _parse_episodes_from_season(self, season_data: dict, series_title: str, season_number: int) -> list[Episode]:
"""Parse episodes from season JSON data."""
episodes = []
for elem in season_data.get("elements", []):
if elem.get("$type") == "bucket" and elem.get("attributes", {}).get("type") == "season":
items = elem.get("attributes", {}).get("items", [])
for idx, item in enumerate(items):
if item.get("type") != "SEASON_VOD":
continue
ep_title = item.get("title", "")
ep_num = idx + 1
# Try to extract episode number from title "E1 - Title"
if ep_title.startswith("E") and " - " in ep_title:
try:
ep_num = int(ep_title.split(" - ")[0][1:])
except ValueError:
pass
episodes.append(Episode(
id_=item["id"],
service=self.__class__,
title=series_title,
season=season_number,
number=ep_num,
name=ep_title,
description=item.get("description", ""),
language=Language.get("ja"),
data=item,
))
break
return episodes
def get_titles(self) -> Titles_T:
anchor_data = self._fetch_season_data(self.content_id)
if self.kind == "movie":
# Find the playlist bucket, then the single VOD
vod_id = None
movie_title = None
description = ""
for elem in data.get("elements", []):
for elem in anchor_data.get("elements", []):
if elem.get("$type") == "hero":
hdr = (elem.get("attributes", {}).get("header", {}) or {}).get("attributes", {})
movie_title = hdr.get("text", movie_title)
for c in elem.get("attributes", {}).get("content", []):
if c.get("$type") == "textblock":
description = c.get("attributes", {}).get("text", description)
if elem.get("$type") == "bucket" and elem.get("attributes", {}).get("type") == "playlist":
items = elem.get("attributes", {}).get("items", [])
if items:
vod_id = items[0]["id"]
if not movie_title:
movie_title = items[0].get("title")
if not description:
description = items[0].get("description", "")
movie_title = movie_title or items[0].get("title")
description = description or items[0].get("description", "")
break
if not vod_id:
@ -157,37 +287,86 @@ class HIDI(Service):
)
])
# Series
episodes = []
series_title = None
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.get("metadata", {}).get("series", {}).get("title", "") or "HiDive",
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.")
return Series(sorted(episodes, key=lambda x: x.number))
series_id, series_title = self._extract_series_info(anchor_data)
series_title = series_title or "HiDive Series"
anchor_season_num = self._extract_season_number(anchor_data)
if not series_id:
self.log.warning("Could not determine Series ID. Fetching single season only.")
episodes = self._parse_episodes_from_season(anchor_data, series_title, anchor_season_num)
return Series(episodes)
try:
adj_data = self._fetch_adjacent_seasons(series_id, self.content_id)
except Exception as e:
self.log.warning(f"Failed to fetch adjacent seasons: {e}. Falling back to single season.")
episodes = self._parse_episodes_from_season(anchor_data, series_title, anchor_season_num)
return Series(episodes)
# Build list of all seasons
all_seasons = []
# Preceding seasons (these come before current season)
for s in adj_data.get("precedingSeasons", []):
all_seasons.append({
"id": s["id"],
"seasonNumber": s.get("seasonNumber", 0),
"title": s.get("title", "")
})
# Current/Anchor season
all_seasons.append({
"id": self.content_id,
"seasonNumber": anchor_season_num,
"title": f"Season {anchor_season_num}",
"_data": anchor_data # Cache to avoid re-fetching
})
# Following seasons (these come after current season)
for s in adj_data.get("followingSeasons", []):
all_seasons.append({
"id": s["id"],
"seasonNumber": s.get("seasonNumber", 0),
"title": s.get("title", "")
})
# Deduplicate by ID and sort by season number
unique_seasons = {}
for s in all_seasons:
s_id = s["id"]
if s_id not in unique_seasons:
unique_seasons[s_id] = s
elif "_data" in s:
# Prefer the one with cached data
unique_seasons[s_id] = s
sorted_seasons = sorted(unique_seasons.values(), key=lambda x: x["seasonNumber"])
all_episodes = []
for season_info in sorted_seasons:
s_id = season_info["id"]
s_num = season_info["seasonNumber"]
if "_data" in season_info:
self.log.info(f"Processing Season {s_num} (ID: {s_id}) [cached]")
season_data = season_info["_data"]
else:
self.log.info(f"Fetching Season {s_num} (ID: {s_id})")
try:
season_data = self._fetch_season_data(s_id)
except Exception as e:
self.log.error(f"Failed to fetch Season {s_num}: {e}")
continue
episodes = self._parse_episodes_from_season(season_data, series_title, s_num)
self.log.info(f" Found {len(episodes)} episodes")
all_episodes.extend(episodes)
if not all_episodes:
raise ValueError("No episodes found across all seasons.")
return Series(all_episodes)
def _get_audio_for_langs(self, mpd_url: str, langs: Iterable[Language]) -> list[Audio]:
merged: list[Audio] = []
@ -300,11 +479,12 @@ class HIDI(Service):
return base_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)
if title.id not in self._drm_cache:
raise ValueError("DRM information not found for this title.")
return self._drm_cache[title.id]
def _decode_hidive_license_payload(self, payload: bytes) -> bytes:

509
HPLA/__init__.py Normal file
View File

@ -0,0 +1,509 @@
import base64
import hashlib
import json
import re
from typing import Optional, Union, Generator
import click
from langcodes import Language
from lxml import etree
from unshackle.core.constants import AnyTrack
from unshackle.core.credential import Credential
from unshackle.core.manifests import DASH
from unshackle.core.search_result import SearchResult
from unshackle.core.service import Service
from unshackle.core.titles import Movie, Movies, Title_T, Titles_T, Song, Album
from unshackle.core.tracks import Chapter, Subtitle, Tracks, Audio
class HPLA(Service):
"""
Service code for Hoopla Digital (https://www.hoopladigital.com)
Version: 1.0.7
Authorization: Credentials (Email & Password)
Security:
- SL2K/SL3K/L1/L3: SD/360p
They are using the license server of DRMToday with encoded streams from CastLabs.
Supports movie and music (but kinda broken) at the moment
Television kinda sucks since you need to borrow it one by one, idk why people would want this shit quality series anyways
Use full URL (for example - https://www.hoopladigital.com/movie/title-name/10979706) or content ID.
"""
ALIASES = ("HPLA", "hoopla")
TITLE_RE = r"^(?:https?://(?:www\.)?hoopladigital\.com/[^/]*/[^/]*/)?(?P<title_id>\d+)"
GEOFENCE = ("US",)
@staticmethod
@click.command(name="HPLA", short_help="https://www.hoopladigital.com")
@click.argument("title", type=str)
@click.option("-m", "--movie", is_flag=True, default=False, help="Specify if it's a movie")
@click.pass_context
def cli(ctx, **kwargs):
return HPLA(ctx, **kwargs)
def __init__(self, ctx, title, movie):
super().__init__(ctx)
self.title = title
self.movie = movie
if self.config is None:
raise Exception("Config is missing!")
profile_name = ctx.parent.params.get("profile")
self.profile = profile_name if profile_name else "default"
self.platform = self.config["platform"]["amazon"]
def authenticate(self, cookies: Optional[any] = None, credential: Optional[Credential] = None) -> None:
super().authenticate(cookies, credential)
if not credential or not credential.username or not credential.password:
raise EnvironmentError("Service requires Credentials for Authentication.")
self.credential = credential
self.session.headers.update(self.platform["headers"])
cache_key = f"tokens_{self.profile}"
cache = self.cache.get(cache_key)
if cache and not cache.expired:
cached_data = cache.data
if isinstance(cached_data, dict) and cached_data.get("username") == credential.username:
self.log.info("Using cached tokens")
self._restore_from_cache(cached_data)
return
self.log.info("Logging in...")
self._do_login(credential)
self._cache_tokens(credential.username, cache_key)
def _restore_from_cache(self, cached_data: dict) -> None:
"""Restore authentication state from cached data."""
self.access_token = cached_data["access_token"]
self.patron_id = cached_data["patron_id"]
self.session.headers.update({
"Authorization": f"Bearer {self.access_token}",
"patron-id": self.patron_id,
})
def _cache_tokens(self, username: str, cache_key: str) -> None:
"""Cache the current authentication tokens."""
cache = self.cache.get(cache_key)
cache.set(
data={
"username": username,
"access_token": self.access_token,
"patron_id": self.patron_id,
},
expiration=3600
)
def _is_music_mpd(self, mpd: etree._Element) -> bool:
"""
Detect if MPD represents a single-file music asset.
"""
adaptation_sets = mpd.findall(".//AdaptationSet")
for aset in adaptation_sets:
if aset.get("contentType") == "video":
return False
audio_reps = mpd.findall(".//AdaptationSet[@contentType='audio']/Representation")
if len(audio_reps) != 1:
return False
if mpd.find(".//SegmentTemplate") is not None:
return False
return mpd.find(".//BaseURL") is not None
def _extract_music_audio(self, mpd: etree._Element, manifest_url: str) -> str:
base = mpd.find(".//BaseURL")
if base is None or not base.text:
raise ValueError("Music MPD has no BaseURL")
return manifest_url.rsplit("/", 1)[0] + "/" + base.text
def _do_login(self, credential: Credential) -> None:
"""Perform full login flow."""
# Step 1: Get Bearer Token
login_response = self.session.post(
url=self.config["endpoints"]["login"],
data={
"username": credential.username,
"password": credential.password,
},
headers={"Content-Type": "application/x-www-form-urlencoded"}
).json()
if login_response.get("tokenStatus") != "SUCCESS":
raise EnvironmentError(f"Login failed: {login_response.get('tokenStatus', 'Unknown error')}")
self.access_token = login_response["token"]
self.session.headers.update({"Authorization": f"Bearer {self.access_token}"})
# Step 2: Get Patron ID
self.log.info("Fetching Patron ID...")
query = 'query { patron { id email } }'
patron_data = self.session.post(
url=self.config["endpoints"]["graphql"],
json={"query": query},
headers={"Content-Type": "application/json"}
).json()
self.patron_id = patron_data["data"]["patron"]["id"]
self.session.headers.update({"patron-id": self.patron_id})
self.log.debug(f"Logged in as Patron ID: {self.patron_id}")
def search(self) -> Generator[SearchResult, None, None]:
query = """
query GetFilterSearchQuery($criteria: SearchCriteria!, $sort: Sort) {
search(criteria: $criteria, sort: $sort) {
hits {
id
title
kind { name }
}
}
}
"""
payload = {
"operationName": "GetFilterSearchQuery",
"variables": {
"criteria": {
"q": self.title,
"availability": "ALL_TITLES",
"pagination": {
"page": 1,
"pageSize": 48,
},
}
},
"query": query,
}
resp = self.session.post(
self.config["endpoints"]["graphql"],
json=payload,
headers={"Content-Type": "application/json"},
).json()
hits = (
resp
.get("data", {})
.get("search", {})
.get("hits", [])
)
for hit in hits:
kind = hit["kind"]["name"]
label = {
"MOVIE": "MOVIE",
"TVSHOW": "SERIES",
"MUSIC": "ALBUM",
"AUDIOBOOK": "AUDIOBOOK",
"EBOOK": "BOOK",
"COMIC": "COMIC",
}.get(kind, kind)
yield SearchResult(
id_=hit["id"],
title=hit["title"],
label=label,
url=f"https://www.hoopladigital.com/title/{hit['id']}",
)
def get_titles(self) -> Titles_T:
title_match = re.match(self.TITLE_RE, self.title)
if not title_match:
raise ValueError(f"Invalid title format: {self.title}")
content_id = title_match.group("title_id")
query = """
query {
contents(criteria:{contentIds:[%s]}) {
contents {
id
title
kind { id name }
mediaKey
circulation { id dueDate }
year
seconds
primaryArtist { name }
tracks {
id
mediaKey
name
seconds
segmentNumber
}
}
}
}
""" % content_id
data = self.session.post(
url=self.config["endpoints"]["graphql"],
json={"query": query},
headers={"Content-Type": "application/json"}
).json()
contents = data.get("data", {}).get("contents", {}).get("contents", [])
if not contents:
raise ValueError("Content not found")
meta = contents[0]
kind_name = meta["kind"]["name"]
if not meta.get("circulation"):
raise ValueError("You must borrow this title on your Hoopla account before downloading.")
if kind_name == "MOVIE":
return Movies([
Movie(
id_=meta["id"],
service=self.__class__,
name=meta["title"],
year=int(meta["year"]) if meta.get("year") else None,
language=Language.get("en"),
data={
"mediaKey": meta["mediaKey"],
"circulationId": meta["circulation"]["id"],
"is_music": False,
},
)
])
elif kind_name == "MUSIC":
if not meta.get("tracks"):
# Single-track album? Use main mediaKey
songs = [
Song(
id_=meta["id"],
service=self.__class__,
name=meta["title"],
artist=meta.get("primaryArtist", {}).get("name", "Unknown Artist"),
album=meta["title"],
track=1,
disc=1,
year=int(meta["year"]) if meta.get("year") else None,
data={
"mediaKey": meta["mediaKey"],
"circulationId": meta["circulation"]["id"],
"is_music": True,
}
)
]
else:
songs = []
for idx, track in enumerate(meta["tracks"], start=1):
songs.append(
Song(
id_=track["id"],
service=self.__class__,
name=track["name"],
artist=meta.get("primaryArtist", {}).get("name", "Unknown Artist"),
album=meta["title"],
track=track.get("segmentNumber", idx),
disc=1,
year=int(meta["year"]) if meta.get("year") else None,
data={
"mediaKey": track["mediaKey"], # ← Per-track mediaKey!
"circulationId": meta["circulation"]["id"],
"is_music": True,
}
)
)
return Album(songs)
else:
raise ValueError(f"Unsupported content type: {kind_name}. Only MOVIE and MUSIC are supported.")
def get_tracks(self, title: Title_T) -> Tracks:
media_key = title.data["mediaKey"]
circulation_id = title.data["circulationId"]
# --- DRM bootstrap ---
self.asset_id = self.session.get(
self.config["endpoints"]["license_asset"].format(media_key=media_key)
).text.strip()
self.auth_token = self.session.get(
self.config["endpoints"]["license_token"].format(
media_key=media_key,
patron_id=self.patron_id,
circulation_id=circulation_id,
)
).text.strip()
self.custom_data = self._extract_custom_data(self.auth_token)
manifest_url = self.config["endpoints"]["manifest"].format(media_key=media_key)
mpd_xml = self.session.get(manifest_url).text
mpd_xml = self._strip_namespaces(mpd_xml)
mpd = etree.fromstring(mpd_xml.encode("utf-8"))
if self._is_music_mpd(mpd):
self.log.info("Detected Hoopla music MPD")
audio_url = self._extract_music_audio(mpd, manifest_url)
tracks = Tracks()
tracks.add(
Audio(
url=audio_url,
drm=[],
codec=Audio.Codec.AAC,
language=title.language or "en",
channels=2,
)
)
return tracks
self.log.info("Detected Hoopla movie MPD")
tracks = DASH(mpd, manifest_url).to_tracks(
language=title.language or Language.get("en")
)
self._add_subtitles(tracks, manifest_url, media_key)
return tracks
def _strip_namespaces(self, xml_string: str) -> str:
"""
Strip namespace declarations and prefixes from XML string.
This is needed because unshackle's DASH parser expects plain 'MPD' tag,
not '{urn:mpeg:dash:schema:mpd:2011}MPD'.
"""
# Remove xmlns declarations (both default and prefixed)
xml_string = re.sub(r'\s+xmlns(:\w+)?="[^"]+"', '', xml_string)
# Remove namespace prefixes from element tags (e.g., <cenc:pssh> -> <pssh>)
xml_string = re.sub(r'<(/?)(\w+):', r'<\1', xml_string)
# Remove namespace prefixes from attributes (e.g., cenc:default_KID -> default_KID)
xml_string = re.sub(r'\s+\w+:(\w+)=', r' \1=', xml_string)
# Remove urn: prefixed attributes entirely (e.g., urn:assetId="...")
xml_string = re.sub(r'\s+urn:\w+="[^"]+"', '', xml_string)
return xml_string
def _extract_custom_data(self, jwt_token: str) -> str:
"""Extract and encode optData from JWT for dt-custom-data header."""
try:
jwt_parts = jwt_token.split(".")
padded_payload = jwt_parts[1] + "=" * (-len(jwt_parts[1]) % 4)
payload_json = json.loads(base64.urlsafe_b64decode(padded_payload))
opt_data_str = payload_json.get("optData")
if not opt_data_str:
raise ValueError("optData not found in JWT")
return base64.b64encode(opt_data_str.encode("utf-8")).decode("utf-8")
except Exception as e:
raise ValueError(f"Failed to process license token: {e}")
def _add_subtitles(self, tracks: Tracks, manifest_url: str, media_key: str) -> None:
"""Add VTT subtitles from manifest if available."""
base_url = manifest_url.rsplit('/', 1)[0]
vtt_patterns = [
f"{base_url}/{media_key}-8784525650515056532-en/{media_key}-8784525650515056532-en.vtt",
]
for vtt_url in vtt_patterns:
try:
response = self.session.head(vtt_url)
if response.status_code == 200:
tracks.add(
Subtitle(
id_=hashlib.md5(vtt_url.encode()).hexdigest()[0:6],
url=vtt_url,
codec=Subtitle.Codec.WebVTT,
language=Language.get("en"),
sdh=True,
)
)
break
except Exception:
pass
def get_chapters(self, title: Title_T) -> list[Chapter]:
return []
def get_widevine_service_certificate(self, **_) -> Optional[str]:
return self.config.get("certificate")
def get_widevine_license(self, *, challenge: bytes, title: Title_T, track: AnyTrack) -> Optional[Union[bytes, str]]:
response = self.session.post(
url=self.config["endpoints"]["license_wv"],
params={
"logRequestId": "unshackle",
"assetId": self.asset_id,
},
headers={
"dt-custom-data": self.custom_data,
"x-dt-auth-token": self.auth_token,
"Content-Type": "text/xml",
},
data=challenge,
)
if response.status_code != 200:
self.log.error(f"License Error: {response.text}")
raise ValueError(f"Failed to get Widevine license: {response.status_code}")
return response.json().get("license")
def get_playready_license(self, *, challenge: bytes | str, title: Title_T, track: AnyTrack) -> bytes:
if not hasattr(self, 'auth_token') or not hasattr(self, 'custom_data'):
raise RuntimeError("Authentication tokens missing. Call get_tracks() first.")
if isinstance(challenge, str):
request_body = challenge.encode('utf-8')
else:
request_body = challenge
headers = {
"Accept": "*/*",
"Accept-Language": "nl",
"Cache-Control": "no-cache",
"Content-Type": "text/xml; charset=utf-8",
"dt-custom-data": self.custom_data,
"x-dt-auth-token": self.auth_token,
"soapaction": '"http://schemas.microsoft.com/DRM/2007/03/protocols/AcquireLicense"',
"Origin": "https://www.hoopladigital.com",
"Referer": "https://www.hoopladigital.com/",
"Pragma": "no-cache",
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36 Edg/144.0.0.0",
}
response = self.session.post(
url=self.config["endpoints"]["license_pr"],
data=request_body,
headers=headers,
timeout=30
)
if response.status_code != 200:
self.log.error(f"PlayReady license failed: {response.status_code}")
self.log.error(f"Response: {response.text[:1000]}")
raise ValueError(f"PlayReady license failed: HTTP {response.status_code}")
return response.content

22
HPLA/config.yaml Normal file
View File

@ -0,0 +1,22 @@
endpoints:
login: https://patron-api-gateway.hoopladigital.com/core/tokens
graphql: https://patron-api-gateway.hoopladigital.com/graphql
manifest: https://dash.hoopladigital.com/{media_key}/Manifest.mpd
license_asset: https://patron-api-gateway.hoopladigital.com/license/castlabs/asset-id/{media_key}
license_token: https://patron-api-gateway.hoopladigital.com/license/castlabs/upfront-auth-tokens/{media_key}/{patron_id}/{circulation_id}
license_wv: https://lic.drmtoday.com/license-proxy-widevine/cenc/
license_pr: https://lic.drmtoday.com/license-proxy-headerauth/drmtoday/RightsManager.asmx?persistent=false
platform:
amazon:
headers:
app: AMAZON
device-model: SM-A525F
os: AMAZON
User-Agent: Hoopla Amazon/4.84.1
app-version: "4.84.1"
os-version: "15"
ws-api: "2.1"
device-version: a52q
hoopla-version: "4.84.1"
Accept-Language: en-US

View File

@ -5,6 +5,7 @@ from datetime import datetime, timezone
from http.cookiejar import CookieJar
from typing import List, Optional
from collections.abc import Generator
import click
import jwt
from langcodes import Language
@ -21,9 +22,9 @@ from unshackle.core.tracks import Subtitle, Tracks
class KNPY(Service):
"""
Service code for Kanopy (kanopy.com).
Version: 1.0.0
Version: 1.1.0
Auth: Credential (username + password)
Auth: Cookies (kapi_token) or Credential (username + password)
Security: FHD@L3
Handles both Movies and Series (Playlists).
@ -31,7 +32,6 @@ class KNPY(Service):
Caching included
"""
# Updated regex to match the new URL structure with library subdomain and path
TITLE_RE = r"^https?://(?:www\.)?kanopy\.com/.+/(?P<id>\d+)$"
GEOFENCE = ()
NO_SUBTITLES = False
@ -73,110 +73,194 @@ class KNPY(Service):
self.widevine_license_url = None
def authenticate(self, cookies: Optional[CookieJar] = None, credential: Optional[Credential] = None) -> None:
if not credential or not credential.username or not credential.password:
raise ValueError("Kanopy requires email and password for authentication.")
cache = self.cache.get("auth_token")
"""
Authenticate using either cookies or credentials.
if cache and not cache.expired:
cached_data = cache.data
valid_token = None
Cookie-based auth: Requires 'kapi_token' cookie from browser.
Credential-based auth: Requires email and password.
"""
if isinstance(cached_data, dict) and "token" in cached_data:
if cached_data.get("username") == credential.username:
valid_token = cached_data["token"]
self.log.info("Using cached authentication token")
else:
self.log.info(f"Cached token belongs to '{cached_data.get('username')}', but logging in as '{credential.username}'. Re-authenticating.")
if cookies:
jwt_token = None
cookie_visitor_id = None
cookie_uid = None
elif isinstance(cached_data, str):
self.log.info("Found legacy cached token format. Re-authenticating to ensure correct user.")
# Extract relevant cookies
for cookie in cookies:
if cookie.name == "kapi_token":
jwt_token = cookie.value
elif cookie.name == "visitor_id":
cookie_visitor_id = cookie.value
elif cookie.name == "uid":
cookie_uid = cookie.value
if valid_token:
self._jwt = valid_token
if jwt_token:
self.log.info("Attempting cookie-based authentication...")
self._jwt = jwt_token
self.session.headers.update({"authorization": f"Bearer {self._jwt}"})
if not self._user_id or not self._domain_id or not self._visitor_id:
try:
decoded_jwt = jwt.decode(self._jwt, options={"verify_signature": False})
self._user_id = decoded_jwt["data"]["uid"]
self._visitor_id = decoded_jwt["data"]["visitor_id"]
self.log.info(f"Extracted user_id and visitor_id from cached token.")
self._fetch_user_details()
return
except (KeyError, jwt.DecodeError) as e:
self.log.error(f"Could not decode cached token: {e}. Re-authenticating.")
try:
# Decode JWT to extract user information
decoded_jwt = jwt.decode(self._jwt, options={"verify_signature": False})
# Check if token is expired
exp_timestamp = decoded_jwt.get("exp")
if exp_timestamp and exp_timestamp < datetime.now(timezone.utc).timestamp():
self.log.warning("Cookie token has expired.")
if credential:
self.log.info("Falling back to credential-based authentication...")
else:
raise ValueError("Cookie token expired and no credentials provided.")
else:
# Extract user data from JWT
jwt_data = decoded_jwt.get("data", {})
self._user_id = jwt_data.get("uid") or cookie_uid
self._visitor_id = jwt_data.get("visitor_id") or cookie_visitor_id
self.log.info("Performing handshake to get visitor token...")
r = self.session.get(self.config["endpoints"]["handshake"])
r.raise_for_status()
handshake_data = r.json()
self._visitor_id = handshake_data["visitorId"]
initial_jwt = handshake_data["jwt"]
self.log.info(f"Logging in as {credential.username}...")
login_payload = {
"credentialType": "email",
"emailUser": {
"email": credential.username,
"password": credential.password
}
}
r = self.session.post(
self.config["endpoints"]["login"],
json=login_payload,
headers={"authorization": f"Bearer {initial_jwt}"}
)
r.raise_for_status()
login_data = r.json()
self._jwt = login_data["jwt"]
self._user_id = login_data["userId"]
self.session.headers.update({"authorization": f"Bearer {self._jwt}"})
self.log.info(f"Successfully authenticated as {credential.username}")
self._fetch_user_details()
try:
decoded_jwt = jwt.decode(self._jwt, options={"verify_signature": False})
exp_timestamp = decoded_jwt.get("exp")
cache_payload = {
"token": self._jwt,
"username": credential.username
}
if exp_timestamp:
expiration_in_seconds = int(exp_timestamp - datetime.now(timezone.utc).timestamp())
self.log.info(f"Caching token for {expiration_in_seconds / 60:.2f} minutes.")
cache.set(data=cache_payload, expiration=expiration_in_seconds)
if not self._user_id:
raise ValueError("Could not extract user_id from cookie token")
self.log.info(f"Successfully authenticated via cookies (user_id: {self._user_id})")
# Fetch user library memberships to get domain_id
self._fetch_user_details()
return
except jwt.DecodeError as e:
self.log.error(f"Failed to decode cookie token: {e}")
if credential:
self.log.info("Falling back to credential-based authentication...")
else:
raise ValueError(f"Invalid kapi_token cookie: {e}")
except KeyError as e:
self.log.error(f"Missing expected field in cookie token: {e}")
if credential:
self.log.info("Falling back to credential-based authentication...")
else:
raise ValueError(f"Invalid kapi_token structure: {e}")
else:
self.log.warning("JWT has no 'exp' claim, caching for 1 hour as a fallback.")
cache.set(data=cache_payload, expiration=3600)
except Exception as e:
self.log.error(f"Failed to decode JWT for caching: {e}. Caching for 1 hour as a fallback.")
cache.set(
data={"token": self._jwt, "username": credential.username},
expiration=3600
self.log.info("No kapi_token found in cookies.")
if not credential:
raise ValueError("No kapi_token cookie found and no credentials provided.")
self.log.info("Falling back to credential-based authentication...")
if not self._jwt: # Only proceed if not already authenticated via cookies
if not credential or not credential.username or not credential.password:
raise ValueError("Kanopy requires either cookies (with kapi_token) or email/password for authentication.")
# Check for cached credential-based token
cache = self.cache.get("auth_token")
if cache and not cache.expired:
cached_data = cache.data
valid_token = None
if isinstance(cached_data, dict) and "token" in cached_data:
if cached_data.get("username") == credential.username:
valid_token = cached_data["token"]
self.log.info("Using cached authentication token")
else:
self.log.info(f"Cached token belongs to '{cached_data.get('username')}', but logging in as '{credential.username}'. Re-authenticating.")
elif isinstance(cached_data, str):
self.log.info("Found legacy cached token format. Re-authenticating to ensure correct user.")
if valid_token:
self._jwt = valid_token
self.session.headers.update({"authorization": f"Bearer {self._jwt}"})
if not self._user_id or not self._domain_id or not self._visitor_id:
try:
decoded_jwt = jwt.decode(self._jwt, options={"verify_signature": False})
self._user_id = decoded_jwt["data"]["uid"]
self._visitor_id = decoded_jwt["data"]["visitor_id"]
self.log.info(f"Extracted user_id and visitor_id from cached token.")
self._fetch_user_details()
return
except (KeyError, jwt.DecodeError) as e:
self.log.error(f"Could not decode cached token: {e}. Re-authenticating.")
# Perform fresh login with credentials
self.log.info("Performing handshake to get visitor token...")
r = self.session.get(self.config["endpoints"]["handshake"])
r.raise_for_status()
handshake_data = r.json()
self._visitor_id = handshake_data["visitorId"]
initial_jwt = handshake_data["jwt"]
self.log.info(f"Logging in as {credential.username}...")
login_payload = {
"credentialType": "email",
"emailUser": {
"email": credential.username,
"password": credential.password
}
}
r = self.session.post(
self.config["endpoints"]["login"],
json=login_payload,
headers={"authorization": f"Bearer {initial_jwt}"}
)
r.raise_for_status()
login_data = r.json()
self._jwt = login_data["jwt"]
self._user_id = login_data["userId"]
self.session.headers.update({"authorization": f"Bearer {self._jwt}"})
self.log.info(f"Successfully authenticated as {credential.username}")
self._fetch_user_details()
# Cache the token
try:
decoded_jwt = jwt.decode(self._jwt, options={"verify_signature": False})
exp_timestamp = decoded_jwt.get("exp")
cache_payload = {
"token": self._jwt,
"username": credential.username
}
if exp_timestamp:
expiration_in_seconds = int(exp_timestamp - datetime.now(timezone.utc).timestamp())
self.log.info(f"Caching token for {expiration_in_seconds / 60:.2f} minutes.")
cache.set(data=cache_payload, expiration=expiration_in_seconds)
else:
self.log.warning("JWT has no 'exp' claim, caching for 1 hour as a fallback.")
cache.set(data=cache_payload, expiration=3600)
except Exception as e:
self.log.error(f"Failed to decode JWT for caching: {e}. Caching for 1 hour as a fallback.")
cache.set(
data={"token": self._jwt, "username": credential.username},
expiration=3600
)
def _fetch_user_details(self):
"""Fetch user library memberships to determine the active domain_id."""
self.log.info("Fetching user library memberships...")
r = self.session.get(self.config["endpoints"]["memberships"].format(user_id=self._user_id))
r.raise_for_status()
memberships = r.json()
# Look for the default active membership
for membership in memberships.get("list", []):
if membership.get("status") == "active" and membership.get("isDefault", False):
self._domain_id = str(membership["domainId"])
self.log.info(f"Using default library domain: {membership.get('sitename', 'Unknown')} (ID: {self._domain_id})")
return
# Fallback to first active membership
for membership in memberships.get("list", []):
if membership.get("status") == "active":
self._domain_id = str(membership["domainId"])
self.log.warning(f"No default library found. Using first active domain: {self._domain_id}")
return
if memberships.get("list"):
self._domain_id = str(memberships["list"][0]["domainId"])
self.log.warning(f"No default library found. Using first active domain: {self._domain_id}")
self.log.warning(f"No active library found. Using first available domain: {self._domain_id}")
else:
raise ValueError("No active library memberships found for this user.")
raise ValueError("No library memberships found for this user.")
def get_titles(self) -> Titles_T:
if not self.content_id:
@ -190,14 +274,17 @@ class KNPY(Service):
content_type = content_data.get("type")
def parse_lang(data):
def parse_lang(taxonomies_data: dict) -> Language:
"""Parses language from the taxonomies dictionary."""
try:
langs = data.get("languages", [])
if langs and isinstance(langs, list) and len(langs) > 0:
return Language.find(langs[0])
except:
langs = taxonomies_data.get("languages", [])
if langs:
lang_name = langs[0].get("name")
if lang_name:
return Language.find(lang_name)
except (IndexError, AttributeError, TypeError):
pass
return Language.get("en")
return Language.get("en") # Default to English
if content_type == "video":
video_data = content_data["video"]
@ -207,22 +294,25 @@ class KNPY(Service):
name=video_data["title"],
year=video_data.get("productionYear"),
description=video_data.get("descriptionHtml", ""),
language=parse_lang(video_data),
language=parse_lang(video_data.get("taxonomies", {})),
data=video_data,
)
return Movies([movie])
elif content_type == "playlist":
playlist_data = content_data["playlist"]
playlist_data = content_data.get("playlist")
if not playlist_data:
raise ValueError("Could not find 'playlist' data dictionary.")
series_title = playlist_data["title"]
series_year = playlist_data.get("productionYear")
season_match = re.search(r'(?:Season|S)\s*(\d+)', series_title, re.IGNORECASE)
season_num = int(season_match.group(1)) if season_match else 1
r = self.session.get(self.config["endpoints"]["video_items"].format(video_id=self.content_id, domain_id=self._domain_id))
r.raise_for_status()
items_data = r.json()
r_items = self.session.get(self.config["endpoints"]["video_items"].format(video_id=self.content_id, domain_id=self._domain_id))
r_items.raise_for_status()
items_data = r_items.json()
episodes = []
for i, item in enumerate(items_data.get("list", [])):
@ -232,8 +322,8 @@ class KNPY(Service):
video_data = item["video"]
ep_num = i + 1
ep_title = video_data.get("title", "")
ep_match = re.search(r'Ep(?:isode)?\.?\s*(\d+)', ep_title, re.IGNORECASE)
ep_title_str = video_data.get("title", "")
ep_match = re.search(r'Ep(?:isode)?\.?\s*(\d+)', ep_title_str, re.IGNORECASE)
if ep_match:
ep_num = int(ep_match.group(1))
@ -247,7 +337,7 @@ class KNPY(Service):
name=video_data["title"],
description=video_data.get("descriptionHtml", ""),
year=video_data.get("productionYear", series_year),
language=parse_lang(video_data),
language=parse_lang(video_data.get("taxonomies", {})),
data=video_data,
)
)
@ -257,6 +347,83 @@ class KNPY(Service):
series.description = playlist_data.get("descriptionHtml", "")
series.year = series_year
return series
elif content_type == "collection":
collection_data = content_data.get("collection")
if not collection_data:
raise ValueError("Could not find 'collection' data dictionary.")
series_title_main = collection_data["title"]
series_description_main = collection_data.get("descriptionHtml", "")
series_year_main = collection_data.get("productionYear")
r_seasons = self.session.get(self.config["endpoints"]["video_items"].format(video_id=self.content_id, domain_id=self._domain_id))
r_seasons.raise_for_status()
seasons_data = r_seasons.json()
all_episodes = []
self.log.info(f"Processing collection '{series_title_main}', found {len(seasons_data.get('list', []))} seasons.")
season_counter = 1
for season_item in seasons_data.get("list", []):
if season_item.get("type") != "playlist":
self.log.warning(f"Skipping unexpected item of type '{season_item.get('type')}' in collection.")
continue
season_playlist_data = season_item["playlist"]
season_id = season_playlist_data["videoId"]
season_title = season_playlist_data["title"]
self.log.info(f"Fetching episodes for season: {season_title}")
season_match = re.search(r'(?:Season|S)\s*(\d+)', season_title, re.IGNORECASE)
if season_match:
season_num = int(season_match.group(1))
else:
self.log.warning(f"Could not parse season number from '{season_title}'. Using sequential number {season_counter}.")
season_num = season_counter
season_counter += 1
r_episodes = self.session.get(self.config["endpoints"]["video_items"].format(video_id=season_id, domain_id=self._domain_id))
r_episodes.raise_for_status()
episodes_data = r_episodes.json()
for i, episode_item in enumerate(episodes_data.get("list", [])):
if episode_item.get("type") != "video":
continue
video_data = episode_item["video"]
ep_num = i + 1
ep_title_str = video_data.get("title", "")
ep_match = re.search(r'Ep(?:isode)?\.?\s*(\d+)', ep_title_str, re.IGNORECASE)
if ep_match:
ep_num = int(ep_match.group(1))
all_episodes.append(
Episode(
id_=str(video_data["videoId"]),
service=self.__class__,
title=series_title_main,
season=season_num,
number=ep_num,
name=video_data["title"],
description=video_data.get("descriptionHtml", ""),
year=video_data.get("productionYear", series_year_main),
language=parse_lang(video_data.get("taxonomies", {})),
data=video_data,
)
)
if not all_episodes:
self.log.error(f"Collection '{series_title_main}' did not yield any episodes. The structure may have changed.")
return Series([])
series = Series(all_episodes)
series.name = series_title_main
series.description = series_description_main
series.year = series_year_main
return series
else:
raise ValueError(f"Unsupported content type: {content_type}")
@ -280,71 +447,152 @@ class KNPY(Service):
except Exception:
pass
# Handle known errors gracefully
if r.status_code == 403:
if response_json and response_json.get("errorSubcode") == "playRegionRestricted":
self.log.error("Kanopy reports: This video is not available in your country.")
raise PermissionError(
"Playback blocked by region restriction. Try connecting through a supported country or verify your librarys access region."
"Playback blocked by region restriction. Try connecting through a supported country or verify your library's access region."
)
else:
self.log.error(f"Access forbidden (HTTP 403). Response: {response_json}")
raise PermissionError("Kanopy denied access to this video. It may require a different library membership or authentication.")
# Raise for any other HTTP errors
r.raise_for_status()
play_data = response_json or r.json()
manifest_url = None
manifest_type = None
drm_info = {}
for manifest in play_data.get("manifests", []):
if manifest["manifestType"] == "dash":
url = manifest["url"]
manifest_url = f"https://kanopy.com{url}" if url.startswith("/") else url
drm_type = manifest.get("drmType")
manifest_type_raw = manifest["manifestType"]
url = manifest["url"].strip()
if url.startswith("/"):
url = f"https://kanopy.com{url}"
drm_type = manifest.get("drmType")
if manifest_type_raw == "dash":
manifest_url = url
manifest_type = "dash"
if drm_type == "kanopyDrm":
play_id = play_data.get("playId")
self.widevine_license_url = self.config["endpoints"]["widevine_license"].format(license_id=f"{play_id}-0")
self.widevine_license_url = self.config["endpoints"]["widevine_license"].format(
license_id=f"{play_id}-0"
)
elif drm_type == "studioDrm":
license_id = manifest.get("drmLicenseID", f"{play_data.get('playId')}-1")
self.widevine_license_url = self.config["endpoints"]["widevine_license"].format(license_id=license_id)
self.widevine_license_url = self.config["endpoints"]["widevine_license"].format(
license_id=license_id
)
else:
self.log.warning(f"Unknown drmType: {drm_type}")
self.log.warning(f"Unknown DASH drmType: {drm_type}")
self.widevine_license_url = None
break
if not manifest_url:
raise ValueError("Could not find a DASH manifest for this title.")
if not self.widevine_license_url:
raise ValueError("Could not construct Widevine license URL.")
elif manifest_type_raw == "hls" and not manifest_url:
manifest_url = url
manifest_type = "hls"
self.log.info(f"Fetching DASH manifest from: {manifest_url}")
if drm_type == "fairplay":
self.log.warning("HLS with FairPlay DRM detected - not currently supported by this service")
self.widevine_license_url = None
drm_info["fairplay"] = True
else:
self.widevine_license_url = None
drm_info["clear"] = True
if not manifest_url:
raise ValueError("Could not find a DASH or HLS manifest for this title.")
if manifest_type == "dash" and not self.widevine_license_url:
raise ValueError("Could not construct Widevine license URL for DASH manifest.")
self.log.info(f"Fetching {manifest_type.upper()} manifest from: {manifest_url}")
r = self.session.get(manifest_url)
r.raise_for_status()
# Refresh headers for manifest parsing
self.session.headers.clear()
self.session.headers.update({
"User-Agent": self.WIDEVINE_UA,
"Accept": "*/*",
"Accept-Encoding": "gzip, deflate",
"Connection": "keep-alive",
})
if manifest_type == "dash":
import xml.etree.ElementTree as ET
# Parse and clean the MPD to remove PlayReady ContentProtection
ET.register_namespace('', 'urn:mpeg:dash:schema:mpd:2011')
ET.register_namespace('cenc', 'urn:mpeg:cenc:2013')
ET.register_namespace('mspr', 'urn:microsoft:playready')
root = ET.fromstring(r.text)
# Remove PlayReady ContentProtection elements
for adaptation_set in root.findall('.//{urn:mpeg:dash:schema:mpd:2011}AdaptationSet'):
for cp in list(adaptation_set.findall('{urn:mpeg:dash:schema:mpd:2011}ContentProtection')):
scheme_id = cp.get('schemeIdUri', '')
# Remove PlayReady but keep Widevine and CENC
if '9a04f079-9840-4286-ab92-e65be0885f95' in scheme_id:
adaptation_set.remove(cp)
self.log.debug("Removed PlayReady ContentProtection element")
cleaned_mpd = ET.tostring(root, encoding='unicode')
tracks = DASH.from_text(cleaned_mpd, url=manifest_url).to_tracks(language=title.language)
elif manifest_type == "hls":
try:
from unshackle.core.manifests import HLS
tracks = HLS.from_text(r.text, url=manifest_url).to_tracks(language=title.language)
self.log.info("Successfully parsed HLS manifest")
except ImportError:
self.log.error(
"HLS manifest parser not available in unshackle.core.manifests. "
"Ensure your unshackle installation supports HLS parsing."
)
raise
except Exception as e:
self.log.error(f"Failed to parse HLS manifest: {e}")
raise
else:
raise ValueError(f"Unsupported manifest type: {manifest_type}")
tracks = DASH.from_text(r.text, url=manifest_url).to_tracks(language=title.language)
# Update session headers for CDN segment downloads
self.session.headers.update({
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"Accept": "*/*",
"Accept-Language": "en-US,en;q=0.9",
"Origin": "https://www.kanopy.com",
"Referer": "https://www.kanopy.com/",
})
# Remove API-specific headers that CDN doesn't need
self.session.headers.pop("x-version", None)
self.session.headers.pop("authorization", None)
# START: SUBTITLE FIX
for caption_data in play_data.get("captions", []):
lang = caption_data.get("language", "en")
# Use the descriptive label for uniqueness, fallback to the language code
label = caption_data.get("label", lang)
# Create a clean, repeatable "slug" from the label for the track ID
slug = label.lower()
slug = re.sub(r'[\s\[\]\(\)]+', '-', slug) # Replace spaces and brackets with hyphens
slug = re.sub(r'[^a-z0-9-]', '', slug) # Remove other non-alphanumeric chars
slug = slug.strip('-')
# Combine with lang code for a robust, unique ID
track_id = f"caption-{lang}-{slug}"
for file_info in caption_data.get("files", []):
if file_info.get("type") == "webvtt":
tracks.add(Subtitle(
id_=f"caption-{lang}",
url=file_info["url"],
id_=track_id,
name=label, # Use the original label for display
url=file_info["url"].strip(),
codec=Subtitle.Codec.WebVTT,
language=Language.get(lang)
))
# Found the file for this caption entry, move to the next one
break
return tracks
# END: SUBTITLE FIX
return tracks
def get_widevine_license(self, *, challenge: bytes, title: Title_T, track: AnyTrack) -> bytes:
if not self.widevine_license_url:
@ -365,43 +613,51 @@ class KNPY(Service):
r.raise_for_status()
return r.content
# def search(self) -> List[SearchResult]:
# if not hasattr(self, 'search_query'):
# self.log.error("Search query not set. Cannot search.")
# return []
def search(self) -> Generator[SearchResult, None, None]:
if not hasattr(self, 'search_query') or not self.search_query:
self.log.error("Search query not set. Cannot search.")
return
# self.log.info(f"Searching for '{self.search_query}'...")
# params = {
# "query": self.search_query,
# "sort": "relevance",
# "domainId": self._domain_id,
# "page": 0,
# "perPage": 20
# }
# r = self.session.get(self.config["endpoints"]["search"], params=params)
# r.raise_for_status()
# search_data = r.json()
self.log.info(f"Searching for '{self.search_query}'...")
# Ensure we have a domain ID (Library ID) before searching
if not self._domain_id:
self._fetch_user_details()
# results = []
# for item in search_data.get("list", []):
# item_type = item.get("type")
# if item_type not in ["playlist", "video"]:
# continue
params = {
"query": self.search_query,
"sort": "relevance",
"domainId": self._domain_id,
"isKids": "false",
"page": 0,
"perPage": 40
}
# video_id = item.get("videoId")
# title = item.get("title", "No Title")
# label = "Series" if item_type == "playlist" else "Movie"
r = self.session.get(self.config["endpoints"]["search"], params=params)
r.raise_for_status()
search_data = r.json()
# The API returns results in a "list" key
results_list = search_data.get("list", [])
if not results_list:
self.log.warning(f"No results found for '{self.search_query}'")
return
for item in results_list:
# Kanopy search results use 'videoId' as the unique identifier
video_id = item.get("videoId")
if not video_id:
continue
title = item.get("title", "Unknown Title")
# results.append(
# SearchResult(
# id_=str(video_id),
# title=title,
# description="",
# label=label,
# url=f"https://www.kanopy.com/watch/{video_id}"
# )
# )
# return results
yield SearchResult(
id_=str(video_id),
title=title,
label="VIDEO/SERIES",
url=f"https://www.kanopy.com/video/{video_id}"
)
def get_chapters(self, title: Title_T) -> list:
return []
return []

View File

@ -12,4 +12,4 @@ endpoints:
search: "https://kanopy.com/kapi/search/videos"
plays: "https://kanopy.com/kapi/plays"
access_expires_in: "https://kanopy.com/kapi/users/{user_id}/history/videos/{video_id}/access_expires_in?domainId={domain_id}"
widevine_license: "https://kanopy.com/kapi/licenses/widevine/{license_id}"
widevine_license: "https://kanopy.com/kapi/licenses/widevine/{license_id}"

View File

@ -14,8 +14,10 @@ from unshackle.core.search_result import SearchResult
from unshackle.core.titles import Episode, Series, Title_T, Titles_T
from unshackle.core.tracks import Subtitle, Tracks
from unshackle.core.utilities import is_close_match
import uuid
import hashlib
class KOWP(Service):
class KOCW(Service):
"""
Service code for Kocowa Plus (kocowa.com).
Version: 1.0.0
@ -29,12 +31,12 @@ class KOWP(Service):
NO_SUBTITLES = False
@staticmethod
@click.command(name="kowp", short_help="https://www.kocowa.com")
@click.command(name="kocw", short_help="https://www.kocowa.com")
@click.argument("title", type=str)
@click.option("--extras", is_flag=True, default=False, help="Include teasers/extras")
@click.pass_context
def cli(ctx, **kwargs):
return KOWP(ctx, **kwargs)
return KOCW(ctx, **kwargs)
def __init__(self, ctx, title: str, extras: bool = False):
super().__init__(ctx)
@ -52,16 +54,27 @@ class KOWP(Service):
if not credential:
raise ValueError("KOWP requires username and password")
email = credential.username.lower().strip()
uuid_seed = hashlib.md5(email.encode()).digest()
fake_uuid = str(uuid.UUID(bytes=uuid_seed[:16]))
device_id = f"a_{fake_uuid}_{email}"
push_token = "fkiTs_a0SAaMYx957n-qA-:APA91bFb39IjJd_iA5bVmh-fjvaUKonvKDWw1PfKKcdpkSXanj0Jlevv_QlMPPD5ZykAQE4ELa3bs6p-Gnmz0R54U-B1o1ukBPLQEDLDdM3hU2ozZIRiy9I"
payload = {
"username": credential.username,
"password": credential.password,
"device_id": f"{credential.username}_browser",
"device_type": "browser",
"device_model": "Firefox",
"device_version": "firefox/143.0",
"device_id": device_id,
"device_type": "mobile",
"device_model": "SM-A525F",
"device_version": "Android 15",
"push_token": None,
"app_version": "v4.0.16",
"app_version": "v4.0.11",
}
self.log.debug(f"Authenticating with device_id: {device_id}")
r = self.session.post(
self.config["endpoints"]["login"],
json=payload,
@ -294,4 +307,3 @@ class KOWP(Service):
def get_chapters(self, title: Title_T) -> list:
return []

View File

@ -7,69 +7,75 @@ from langcodes import Language
import base64
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.credential import Credential
from unshackle.core.titles import Episode, Movie, Movies, Title_T, Titles_T, Series
from unshackle.core.tracks import Chapter, Tracks, Subtitle
from unshackle.core.search_result import SearchResult
class MUBI(Service):
"""
Service code for MUBI (mubi.com)
Version: 1.2.0
Authorization: Required cookies (lt token + session)
Security: FHD @ L3 (Widevine)
Version: 1.2.1 (Cookie-only + Auto-UHD + Search)
Authorization: Cookies ONLY (lt token + _mubi_session)
Security: UHD @ L3/SL2K (Widevine/PlayReady)
Supports:
Series https://mubi.com/en/nl/series/twin-peaks
Movies https://mubi.com/en/nl/films/the-substance
Series https://mubi.com/en/nl/series/twin-peaks
Movies https://mubi.com/en/nl/films/the-substance
"""
SERIES_TITLE_RE = r"^https?://(?:www\.)?mubi\.com(?:/[^/]+)*?/series/(?P<series_slug>[^/]+)(?:/season/(?P<season_slug>[^/]+))?$"
TITLE_RE = r"^(?:https?://(?:www\.)?mubi\.com)(?:/[^/]+)*?/films/(?P<slug>[^/?#]+)$"
NO_SUBTITLES = False
@staticmethod
@click.command(name="MUBI", short_help="https://mubi.com")
@click.command(name="MUBI", short_help="https://mubi.com ")
@click.argument("title", type=str)
@click.option("-c", "--country", default=None, type=str,
help="With VPN set country code other than the one assigned to the account.")
@click.pass_context
def cli(ctx, **kwargs):
return MUBI(ctx, **kwargs)
def __init__(self, ctx, title: str):
def __init__(self, ctx, title: str, country: str):
super().__init__(ctx)
self.raw_title = title # Store raw input for search mode
self.country = country
# Only parse as URL if it matches MUBI patterns
m_film = re.match(self.TITLE_RE, title)
m_series = re.match(self.SERIES_TITLE_RE, title)
if not m_film and not m_series:
raise ValueError(f"Invalid MUBI URL: {title}")
self.is_series = bool(m_series)
self.slug = m_film.group("slug") if m_film else None
self.series_slug = m_series.group("series_slug") if m_series else None
self.season_slug = m_series.group("season_slug") if m_series else None
# Core state
self.film_id: Optional[int] = None
self.lt_token: Optional[str] = None
self.session_token: Optional[str] = None
self.user_id: Optional[int] = None
self.country_code: Optional[str] = None
self.set_country_code: Optional[str] = country
self.anonymous_user_id: Optional[str] = None
self.default_country: Optional[str] = None
self.reels_data: Optional[list] = None
# Store CDM reference
self.reels_data: Optional[list] = None
# ALWAYS enable UHD/HEVC path - no user flag required
self.uhd = True
self.cdm = ctx.obj.cdm
if self.config is None:
raise EnvironmentError("Missing service config for MUBI.")
def authenticate(self, cookies: Optional[CookieJar] = None, credential: Optional[Credential] = None) -> None:
super().authenticate(cookies, credential)
if not cookies:
raise PermissionError("MUBI requires login cookies (lt + _mubi_session). Credentials login is not supported.")
# IP geolocation for country detection
try:
r_ip = self.session.get(self.config["endpoints"]["ip_geolocation"], timeout=5)
r_ip.raise_for_status()
@ -82,36 +88,34 @@ class MUBI(Service):
except Exception as e:
raise ValueError(f"Failed to fetch IP geolocation: {e}")
if not cookies:
raise PermissionError("MUBI requires login cookies.")
# Extract essential tokens
# Extract essential tokens from cookies
lt_cookie = next((c for c in cookies if c.name == "lt"), None)
session_cookie = next((c for c in cookies if c.name == "_mubi_session"), None)
snow_id_cookie = next((c for c in cookies if c.name == "_snow_id.c006"), None)
if not lt_cookie:
raise PermissionError("Missing 'lt' cookie (Bearer token).")
if not session_cookie:
raise PermissionError("Missing '_mubi_session' cookie.")
self.lt_token = lt_cookie.value
self.session_token = session_cookie.value
# Extract anonymous_user_id from _snow_id.c006
# Extract or generate anonymous_user_id
if snow_id_cookie and "." in snow_id_cookie.value:
self.anonymous_user_id = snow_id_cookie.value.split(".")[0]
else:
self.anonymous_user_id = str(uuid.uuid4())
self.log.warning(f"No _snow_id.c006 cookie found — generated new anonymous_user_id: {self.anonymous_user_id}")
# Configure session headers for UHD access
base_headers = {
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) Firefox/143.0",
"User-Agent": "Mozilla/5.0 (Linux; Android 13; SM-G975F) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36",
"Origin": "https://mubi.com",
"Referer": "https://mubi.com/",
"CLIENT": "web",
"Client-Accept-Video-Codecs": "h265,vp9,h264",
"Client-Accept-Audio-Codecs": "aac",
"Client-Accept-Audio-Codecs": "eac3,ac3,aac",
"Authorization": f"Bearer {self.lt_token}",
"ANONYMOUS_USER_ID": self.anonymous_user_id,
"Client-Country": self.default_country,
@ -121,22 +125,24 @@ class MUBI(Service):
"Pragma": "no-cache",
"Cache-Control": "no-cache",
}
self.session.headers.update(base_headers)
# Fetch account info
r_account = self.session.get(self.config["endpoints"]["account"])
if not r_account.ok:
raise PermissionError(f"Failed to fetch MUBI account: {r_account.status_code} {r_account.text}")
account_data = r_account.json()
self.user_id = account_data.get("id")
self.country_code = (account_data.get("country") or {}).get("code", "NL")
if self.set_country_code is not None:
self.country_code = self.set_country_code.upper()
self.session.headers["Client-Country"] = self.country_code
self.GEOFENCE = (self.country_code,)
self._bind_anonymous_user()
self.log.info(
f"Authenticated as user {self.user_id}, "
f"country: {self.country_code}, "
@ -168,31 +174,29 @@ class MUBI(Service):
r = self.session.get(url)
r.raise_for_status()
data = r.json()
self.film_id = data["id"]
# Fetch reels to get definitive language code and cache the response
# Fetch reels for language detection and subtitle names
url_reels = self.config["endpoints"]["reels"].format(film_id=self.film_id)
r_reels = self.session.get(url_reels)
r_reels.raise_for_status()
self.reels_data = r_reels.json()
# Extract original language from the first audio track of the first reel
original_language_code = "en" # Default fallback
# Detect original language from first audio track
original_language_code = "en"
if self.reels_data and self.reels_data[0].get("audio_tracks"):
first_audio_track = self.reels_data[0]["audio_tracks"][0]
if "language_code" in first_audio_track:
original_language_code = first_audio_track["language_code"]
self.log.debug(f"Detected original language from reels: '{original_language_code}'")
genres = ", ".join(data.get("genres", [])) or "Unknown"
self.log.debug(f"Detected original language from reels: '{original_language_code}'")
description = (
data.get("default_editorial_html", "")
.replace("<p>", "").replace("</p>", "").replace("<em>", "").replace("</em>", "").strip()
)
year = data.get("year")
name = data.get("title", "Unknown")
movie = Movie(
id_=self.film_id,
service=self.__class__,
@ -202,19 +206,15 @@ class MUBI(Service):
language=Language.get(original_language_code),
data=data,
)
return Movies([movie])
def _get_series_titles(self) -> Titles_T:
# Fetch series metadata
series_url = self.config["endpoints"]["series"].format(series_slug=self.series_slug)
r_series = self.session.get(series_url)
r_series.raise_for_status()
series_data = r_series.json()
episodes = []
# If season is explicitly specified, only fetch that season
if self.season_slug:
eps_url = self.config["endpoints"]["season_episodes"].format(
series_slug=self.series_slug,
@ -227,123 +227,126 @@ class MUBI(Service):
episodes_data = r_eps.json().get("episodes", [])
self._add_episodes_to_list(episodes, episodes_data, series_data)
else:
# No season specified fetch ALL seasons
seasons = series_data.get("seasons", [])
if not seasons:
raise ValueError("No seasons found for this series.")
for season in seasons:
season_slug = season["slug"]
eps_url = self.config["endpoints"]["season_episodes"].format(
series_slug=self.series_slug,
season_slug=season_slug
)
self.log.debug(f"Fetching episodes for season: {season_slug}")
r_eps = self.session.get(eps_url)
# Stop if season returns 404 or empty
if r_eps.status_code == 404:
self.log.info(f"Season '{season_slug}' not available, skipping.")
continue
r_eps.raise_for_status()
episodes_data = r_eps.json().get("episodes", [])
if not episodes_data:
self.log.info(f"No episodes found in season '{season_slug}'.")
continue
self._add_episodes_to_list(episodes, episodes_data, series_data)
from unshackle.core.titles import Series
return Series(sorted(episodes, key=lambda x: (x.season, x.number)))
def _add_episodes_to_list(self, episodes_list: list, episodes_data: list, series_data: dict):
"""Helper to avoid code duplication when adding episodes."""
for ep in episodes_data:
# Use episode's own language detection via its consumable.playback_languages
playback_langs = ep.get("consumable", {}).get("playback_languages", {})
audio_langs = playback_langs.get("audio_options", ["English"])
lang_code = audio_langs[0].split()[0].lower() if audio_langs else "en"
try:
detected_lang = Language.get(lang_code)
except:
detected_lang = Language.get("en")
episodes_list.append(Episode(
id_=ep["id"],
service=self.__class__,
title=series_data["title"], # Series title
title=series_data["title"],
season=ep["episode"]["season_number"],
number=ep["episode"]["number"],
name=ep["title"], # Episode title
name=ep["title"],
description=ep.get("short_synopsis", ""),
language=detected_lang,
data=ep, # Full episode data for later use in get_tracks
data=ep,
))
def get_tracks(self, title: Title_T) -> Tracks:
film_id = getattr(title, "id", None)
if not film_id:
raise RuntimeError("Title ID not found.")
# For series episodes, we don't have reels cached, so skip reel-based logic
# Initiate viewing session
url_view = self.config["endpoints"]["initiate_viewing"].format(film_id=film_id)
r_view = self.session.post(url_view, json={}, headers={"Content-Type": "application/json"})
r_view.raise_for_status()
view_data = r_view.json()
reel_id = view_data["reel_id"]
# For films, use reels data for language/audio mapping
if not self.is_series:
if not self.film_id:
raise RuntimeError("film_id not set. Call get_titles() first.")
if not self.reels_data:
self.log.warning("Reels data not cached, fetching now.")
url_reels = self.config["endpoints"]["reels"].format(film_id=film_id)
r_reels = self.session.get(url_reels)
r_reels.raise_for_status()
reels = r_reels.json()
else:
reels = self.reels_data
reel = next((r for r in reels if r["id"] == reel_id), reels[0])
else:
# For episodes, we dont need reel-based logic — just proceed
pass
# Request secure streaming URL, works for both films and episodes
# Fetch reels data if not cached
if not self.film_id:
self.film_id = film_id
if not self.reels_data:
url_reels = self.config["endpoints"]["reels"].format(film_id=film_id)
r_reels = self.session.get(url_reels)
r_reels.raise_for_status()
self.reels_data = r_reels.json()
reels = self.reels_data
text_tracks_reel = reels[0]["text_tracks"]
reel = next((r for r in reels if r["id"] == reel_id), reels[0])
# Get secure streaming URL
url_secure = self.config["endpoints"]["secure_url"].format(film_id=film_id)
r_secure = self.session.get(url_secure)
r_secure.raise_for_status()
secure_data = r_secure.json()
# Find DASH manifest URL
manifest_url = None
for entry in secure_data.get("urls", []):
if entry.get("content_type") == "application/dash+xml":
manifest_url = entry["src"]
break
if not manifest_url:
raise ValueError("No DASH manifest URL found.")
# Parse DASH, use title.language as fallback
manifest_url = re.sub(
r'/default/ver1\.AVC1\.[^/]*\.mpd',
'/default/ver1.hevc.ex-vtt.mpd',
manifest_url
)
# Fallback for non-AVC URLs
if '/default/ver1.hevc.ex-vtt.mpd' not in manifest_url:
manifest_url = re.sub(
r'/default/[^/]*\.mpd',
'/default/ver1.hevc.ex-vtt.mpd',
manifest_url
)
# Parse DASH manifest
tracks = DASH.from_url(manifest_url, session=self.session).to_tracks(language=title.language)
# Add subtitles
# Add enhanced subtitles (forced/SDH detection)
subtitles = []
for sub in secure_data.get("text_track_urls", []):
lang_code = sub.get("language_code", "und")
vtt_url = sub.get("url")
role = sub.get("role")
forced = False
sdh = False
if not vtt_url:
continue
try:
disp_name = (next(filter(lambda x: x['id'] == sub["id"], text_tracks_reel), None))["display_name"]
except:
disp_name = sub.get("role", "") + " " + lang_code.upper()
if role == "forced-subtitle":
forced = True
if role == "caption":
sdh = True
if "(SDH)" in disp_name:
disp_name = disp_name.replace("(SDH)", "").strip()
is_original = lang_code == title.language.language
subtitles.append(
Subtitle(
id_=sub["id"],
@ -351,38 +354,64 @@ class MUBI(Service):
language=Language.get(lang_code),
is_original_lang=is_original,
codec=Subtitle.Codec.WebVTT,
name=sub.get("display_name", lang_code.upper()),
forced=False,
sdh=False,
name=disp_name,
forced=forced,
sdh=sdh,
)
)
tracks.subtitles = subtitles
return tracks
def search(self) -> Generator[SearchResult, None, None]:
"""
Search MUBI films using official API endpoint.
Returns only playable films with proper metadata formatting.
"""
params = {
"query": self.raw_title,
"page": 1,
"per_page": 24,
"playable": "true",
"all_films_on_zero_hits": "true"
}
response = self.session.get(
url=self.config["endpoints"]["search"],
params=params
)
response.raise_for_status()
results = response.json()
for film in results.get("films", []):
display_title = f"{film['title']} ({film['year']})"
yield SearchResult(
id_=film["id"],
title=display_title,
label="MOVIE",
url=film["web_url"].rstrip() # Clean trailing spaces
)
def get_chapters(self, title: Title_T) -> list[Chapter]:
return []
def get_widevine_license(self, challenge: bytes, title: Title_T, track: AnyTrack) -> bytes:
if not self.user_id:
raise RuntimeError("user_id not set — authenticate first.")
# Cookie-based license request (NO dtinfo - credentials removed)
dt_custom_data = {
"userId": self.user_id,
"sessionId": self.lt_token,
"merchant": "mubi"
}
dt_custom_data_b64 = base64.b64encode(json.dumps(dt_custom_data).encode()).decode()
headers = {
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:143.0) Gecko/20100101 Firefox/143.0",
"User-Agent": "Mozilla/5.0 (Linux; Android 13; SM-G975F) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36",
"Accept": "*/*",
"Origin": "https://mubi.com",
"Referer": "https://mubi.com/",
"dt-custom-data": dt_custom_data_b64,
}
r = self.session.post(
self.config["endpoints"]["license"],
data=challenge,
@ -394,3 +423,30 @@ class MUBI(Service):
raise PermissionError(f"DRM license error: {license_data}")
return base64.b64decode(license_data["license"])
def get_playready_license(self, challenge: bytes, title: Title_T, track: AnyTrack) -> bytes:
if not self.user_id:
raise RuntimeError("user_id not set — authenticate first.")
# Cookie-based PlayReady license request (NO dtinfo - credentials removed)
dt_custom_data = {
"userId": self.user_id,
"sessionId": self.lt_token,
"merchant": "mubi"
}
dt_custom_data_b64 = base64.b64encode(json.dumps(dt_custom_data).encode()).decode()
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36 Edg/144.0.0.0",
"Accept": "*/*",
"Origin": "https://mubi.com",
"Referer": "https://mubi.com/",
"dt-custom-data": dt_custom_data_b64,
}
r = self.session.post(
self.config["endpoints"]["license_pr"],
data=challenge,
headers=headers,
)
r.raise_for_status()
if r.status_code != 200:
raise PermissionError(f"DRM license error")
return r.content

View File

@ -1,12 +1,14 @@
endpoints:
account: "https://api.mubi.com/v4/account"
current_user: "https://api.mubi.com/v4/current_user"
film_by_slug: "https://api.mubi.com/v4/films/{slug}"
playback_languages: "https://api.mubi.com/v4/films/{film_id}/playback_languages"
initiate_viewing: "https://api.mubi.com/v4/films/{film_id}/viewing?parental_lock_enabled=true"
reels: "https://api.mubi.com/v4/films/{film_id}/reels"
secure_url: "https://api.mubi.com/v4/films/{film_id}/viewing/secure_url"
license: "https://lic.drmtoday.com/license-proxy-widevine/cenc/"
ip_geolocation: "https://directory.cookieyes.com/api/v1/ip"
series: "https://api.mubi.com/v4/series/{series_slug}"
season_episodes: "https://api.mubi.com/v4/series/{series_slug}/seasons/{season_slug}/episodes/available"
account: "https://api.mubi.com/v4/account"
current_user: "https://api.mubi.com/v4/current_user"
film_by_slug: "https://api.mubi.com/v4/films/{slug}"
playback_languages: "https://api.mubi.com/v4/films/{film_id}/playback_languages"
initiate_viewing: "https://api.mubi.com/v4/films/{film_id}/viewing?parental_lock_enabled=true"
reels: "https://api.mubi.com/v4/films/{film_id}/reels"
secure_url: "https://api.mubi.com/v4/films/{film_id}/viewing/secure_url"
license: "https://lic.drmtoday.com/license-proxy-widevine/cenc/"
ip_geolocation: "https://directory.cookieyes.com/api/v1/ip"
series: "https://api.mubi.com/v4/series/{series_slug}"
season_episodes: "https://api.mubi.com/v4/series/{series_slug}/seasons/{season_slug}/episodes/available"
license_pr: "https://lic.drmtoday.com/license-proxy-headerauth/drmtoday/RightsManager.asmx?persistent=false"
search: "https://api.mubi.com/v4/search/films"

View File

@ -105,41 +105,67 @@ class NPO(Service):
def get_titles(self) -> Titles_T:
next_data = self._fetch_next_data(self.slug)
build_id = next_data["buildId"] # keep if needed elsewhere
page_props = next_data["props"]["pageProps"]
queries = page_props["dehydratedState"]["queries"]
def get_data(fragment: str):
def get_query_data(fragment: str):
return next((q["state"]["data"] for q in queries if fragment in str(q.get("queryKey", ""))), None)
if self.kind == "serie":
series_data = get_data("series:detail-")
series_data = get_query_data("series:detail-")
if not series_data:
raise ValueError("Series metadata not found")
episodes = []
seasons = get_data("series:seasons-") or []
for season in seasons:
eps = get_data(f"programs:season-{season['guid']}") or []
for e in eps:
episodes.append(
# Get list of all available seasons
seasons_list = get_query_data("series:seasons-") or []
if not seasons_list:
self.log.warning("No seasons found for this series.")
all_episodes = []
series_type = series_data.get("type", "timeless_series")
for season in seasons_list:
season_guid = season["guid"]
season_number = int(season.get("seasonKey", 0))
# Try to find episode data in the initial page data first
eps_data = get_query_data(f"programs:season-{season_guid}")
# If not in initial data, fetch from the API
if not eps_data:
r = self.session.get(
self.config["endpoints"]["series_episodes"],
params={
"guid": season_guid,
"type": series_type,
"includePremiumContent": "true"
}
)
if r.ok:
eps_data = r.json()
if not eps_data:
continue
for e in eps_data:
all_episodes.append(
Episode(
id_=e["guid"],
service=self.__class__,
title=series_data["title"],
season=int(season["seasonKey"]),
number=int(e["programKey"]),
name=e["title"],
season=season_number,
number=int(e.get("programKey") or 0),
name=e.get("title"),
description=(e.get("synopsis", {}) or {}).get("long", ""),
language=Language.get("nl"),
data=e,
)
)
return Series(episodes)
return Series(all_episodes)
# Movie
item = get_data("program:detail-") or queries[0]["state"]["data"]
# Movie Logic
item = get_query_data("program:detail-") or queries[0]["state"]["data"]
synopsis = item.get("synopsis", {})
desc = synopsis.get("long") or synopsis.get("short", "") if isinstance(synopsis, dict) else str(synopsis)
year = (int(item["firstBroadcastDate"]) // 31536000 + 1970) if item.get("firstBroadcastDate") else None

View File

@ -2,9 +2,10 @@ endpoints:
metadata: "https://npo.nl/start/_next/data/{build_id}/video/{slug}.json"
metadata_series: "https://npo.nl/start/_next/data/{build_id}/serie/{slug}/afleveringen.json"
metadata_episode: "https://npo.nl/start/_next/data/{build_id}/serie/{series_slug}/seizoen-{season_slug}/{episode_slug}.json"
series_episodes: "https://npo.nl/start/api/domain/programs-by-season"
streams: "https://prod.npoplayer.nl/stream-link"
player_token: "https://npo.nl/start/api/domain/player-token?productId={product_id}"
license: "https://npo-drm-gateway.samgcloud.nepworldwide.nl/authentication"
homepage: "https://npo.nl/start"
search: " https://npo.nl/start/api/domain/search-collection-items"
DrmType: "widevine"
search: "https://npo.nl/start/api/domain/search-collection-items"
DrmType: "widevine"

View File

@ -16,24 +16,26 @@ from unshackle.core.tracks import Tracks
class PTHS(Service):
"""
Service code for Pathé Thuis (pathe-thuis.nl)
Version: 1.0.0
Version: 1.1.0 (PlayReady Support Added)
Security: SD @ L3 (Widevine)
FHD @ L1
Authorization: Cookies or authentication token
Security: SD/FHD @ L1/L3 (Widevine)
SD/FHD @ SL2K/SL3K (Playready)
Authorization: Cookies with authenticationToken + XSRF-TOKEN
Supported:
Movies https://www.pathe-thuis.nl/film/{id}
Note:
Pathé Thuis does not have episodic content, only movies.
Subtitles are hardcoded here so yeah I can't do anything about it
The quality is depend on what you rented for, is it SD or HD?
"""
TITLE_RE = (
r"^(?:https?://(?:www\.)?pathe-thuis\.nl/film/)?(?P<id>\d+)(?:/[^/]+)?$"
)
GEOFENCE = ("NL",)
NO_SUBTITLES = True
NO_SUBTITLES = True
@staticmethod
@click.command(name="PTHS", short_help="https://www.pathe-thuis.nl")
@ -44,17 +46,15 @@ class PTHS(Service):
def __init__(self, ctx, title: str):
super().__init__(ctx)
m = re.match(self.TITLE_RE, title)
if not m:
raise ValueError(
f"Unsupported Pathé Thuis URL or ID: {title}\n"
"Use e.g. https://www.pathe-thuis.nl/film/30591"
)
self.movie_id = m.group("id")
self.drm_token = None
self.license_url = None
if self.config is None:
raise EnvironmentError("Missing service config for Pathé Thuis.")
@ -65,18 +65,27 @@ class PTHS(Service):
self.log.warning("No cookies provided, proceeding unauthenticated.")
return
token = next((c.value for c in cookies if c.name == "authenticationToken"), None)
if not token:
# Extract critical cookies
auth_token = next((c.value for c in cookies if c.name == "authenticationToken"), None)
xsrf_token = next((c.value for c in cookies if c.name == "XSRF-TOKEN"), None)
if not auth_token:
self.log.info("No authenticationToken cookie found, unauthenticated mode.")
return
self.session.headers.update({
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:143.0) Gecko/20100101 Firefox/143.0",
"X-Pathe-Device-Identifier": "web-widevine-1",
"X-Pathe-Auth-Session-Token": token,
})
self.log.info("Authentication token successfully attached to session.")
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36 Edg/144.0.0.0",
"X-Pathe-Device-Identifier": "web-1",
"X-Pathe-Auth-Session-Token": auth_token,
}
if xsrf_token:
headers["X-XSRF-TOKEN"] = xsrf_token
self.log.debug(f"XSRF-TOKEN header set: {xsrf_token[:10]}...")
self.session.headers.update(headers)
auth_status = "with XSRF" if xsrf_token else "without XSRF"
self.log.info(f"Authentication token attached ({auth_status}).")
def get_titles(self) -> Titles_T:
url = self.config["endpoints"]["metadata"].format(movie_id=self.movie_id)
@ -90,16 +99,16 @@ class PTHS(Service):
name=data["name"],
description=data.get("intro", ""),
year=data.get("year"),
language=Language.get(data.get("language", "en")),
language=Language.get(data.get("language", "nl")), # Default to Dutch
data=data,
)
return Movies([movie])
def get_tracks(self, title: Title_T) -> Tracks:
ticket_id = self._get_ticket_id(title)
url = self.config["endpoints"]["ticket"].format(ticket_id=ticket_id)
base_url = self.config["endpoints"]["ticket"].format(ticket_id=ticket_id)
url = f"{base_url}?drmType=dash-widevine"
r = self.session.get(url)
r.raise_for_status()
data = r.json()
@ -107,16 +116,17 @@ class PTHS(Service):
manifest_url = stream.get("url") or stream.get("drmurl")
if not manifest_url:
raise ValueError("No stream manifest URL found.")
raise ValueError("No stream manifest URL found in ticket response.")
# Store DRM context for license acquisition
self.drm_token = stream["token"]
self.license_url = stream["rawData"]["licenseserver"]
drm_type = stream["rawData"].get("type", "unknown")
self.log.info(f"Acquired {drm_type.upper()} stream manifest. License URL set.")
tracks = DASH.from_url(manifest_url, session=self.session).to_tracks(language=title.language)
return tracks
def _get_ticket_id(self, title: Title_T) -> str:
"""Fetch the user's owned ticket ID if present."""
data = title.data
@ -125,12 +135,45 @@ class PTHS(Service):
return str(t["id"])
raise ValueError("No valid ticket found for this movie. Ensure purchase or login.")
def get_chapters(self, title: Title_T):
return []
def get_playready_license(self, challenge: bytes, title: Title_T, track: AnyTrack) -> bytes:
"""
Acquire PlayReady license using the authentication token.
Matches the license request pattern observed in browser traffic.
"""
if not self.license_url or not self.drm_token:
raise ValueError("Missing license URL or DRM token. Call get_tracks() first.")
headers = {
"Content-Type": "application/octet-stream",
"Authorization": f"Bearer {self.drm_token}",
}
params = {"custom_data": self.drm_token}
self.log.debug(f"Requesting PlayReady license from {self.license_url}")
r = self.session.post(
self.license_url,
params=params,
data=challenge,
headers=headers,
timeout=10
)
r.raise_for_status()
if not r.content or len(r.content) < 10:
raise ValueError(
"Invalid PlayReady license response. "
"Check: 1) Valid session 2) XSRF token 3) Active rental/purchase"
)
self.log.info(f"Successfully acquired PlayReady license ({len(r.content)} bytes)")
return r.content
def get_widevine_license(self, challenge: bytes, title: Title_T, track: AnyTrack) -> bytes:
"""Widevine license acquisition . """
if not self.license_url or not self.drm_token:
raise ValueError("Missing license URL or token.")
@ -138,7 +181,6 @@ class PTHS(Service):
"Content-Type": "application/octet-stream",
"Authorization": f"Bearer {self.drm_token}",
}
params = {"custom_data": self.drm_token}
r = self.session.post(self.license_url, params=params, data=challenge, headers=headers)
@ -146,4 +188,4 @@ class PTHS(Service):
if not r.content:
raise ValueError("Empty license response, likely invalid or expired token.")
return r.content
return r.content

View File

@ -14,27 +14,38 @@
- Audio mislabel as English
- To add Playready Support
3. PTHS:
- To add Playready Support (is needed since L3 is just 480p)
- Search Functionality
- Account login if possible
4. HIDI:
- 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)
5. MUBI:
- Search Functionality
- Creds login
6. VIKI:
- CSRF Token is now scraped, would be from a api requests soon
7. VIDO:
- Subtitle has little quirk of having javanese and sundanese language labeled on the HLS one but not the DASH one
- Search functionality not available yet
8. KNPY:
- Need to fix the search function
- HLS downloading is not working
9. VRT:
- Search functionality
- Fixing few hickups
10. SKST (the hardest service I ever dealt upon now):
- Subtitles is a litte bit hit or miss for movies and for series there's still no subtitles
- Subtitle has been fixed, hopefully no issue
11. VLD:
- All seems fine working for now
12. HPLA:
- No support for Television yet
- Music needs to be fixed since the output is a mp4 instead of m4a
13. SHUD:
- PlayReady needed
14. GLA:
- Subs sometimes broken (it's on there side)
15. CPY:
- Currently it supports only 720p because there is no TV parameter, needed that
- Acknowledgment
Thanks to Adef for the NPO start downloader.
Thanks to UPS0 for fixing MUBI script

718
SHUD/__init__.py Normal file
View File

@ -0,0 +1,718 @@
import base64
import hashlib
import json
import re
from collections.abc import Generator
from datetime import datetime, timedelta
from http.cookiejar import CookieJar
from typing import Optional, Union
import click
from langcodes import Language
from urllib.parse import parse_qs, urlparse
from unshackle.core.constants import AnyTrack
from unshackle.core.credential import Credential
from unshackle.core.manifests import DASH, HLS
from unshackle.core.search_result import SearchResult
from unshackle.core.service import Service
from unshackle.core.titles import Episode, Movie, Movies, Series, Title_T, Titles_T
from unshackle.core.tracks import Chapter, Subtitle, Tracks, Video
class SHUD(Service):
"""
Service code for watch.shudder.com
Version: 1.0.0
Authorization: Bearer JWT Token
Security: FHD@L3
Use full URL (for example - https://watch.shudder.com/watch/927436) or title ID (for example - 927436).
"""
TITLE_RE = r"^(?:https?://watch\.shudder\.com/[^/]+/)?(?P<title_id>\d+)"
GEOFENCE = ("US", "CA", "GB", "AU", "IE", "NZ")
NO_SUBTITLES = False
VIDEO_RANGE_MAP = {
"SDR": "sdr",
"HDR10": "hdr10",
"DV": "dolby_vision",
}
@staticmethod
@click.command(name="SHUD", short_help="https://watch.shudder.com")
@click.argument("title", type=str)
@click.option("-m", "--movie", is_flag=True, default=False, help="Specify if it's a movie")
@click.option("-d", "--device", type=str, default="web", help="Select device from the config file")
@click.pass_context
def cli(ctx, **kwargs):
return SHUD(ctx, **kwargs)
def __init__(self, ctx, title, movie, device):
super().__init__(ctx)
self.title = title
self.movie = movie
self.device = device
self.cdm = ctx.obj.cdm
# Track request overrides based on device/CDM capabilities
if any(r != Video.Range.SDR for r in self.track_request.ranges):
self.track_request.codecs = [Video.Codec.HEVC]
if self.cdm and self.cdm.security_level == 3:
self.track_request.codecs = [Video.Codec.AVC]
self.track_request.ranges = [Video.Range.SDR]
if self.config is None:
raise Exception("Config is missing!")
profile_name = ctx.parent.params.get("profile")
self.profile = profile_name or "default"
self.license_data = {}
self.realm = "dce.shudder"
self.api_key = self.config["api_key"]
def authenticate(self, cookies: Optional[CookieJar] = None, credential: Optional[Credential] = None) -> None:
super().authenticate(cookies, credential)
# Set required headers for all requests
self.session.headers.update({
"User-Agent": self.config["client"][self.device]["user_agent"],
"Accept": "application/json, text/plain, */*",
"Accept-Language": "en-US",
"Accept-Encoding": "gzip, deflate, br, zstd",
"Referer": "https://watch.shudder.com/",
"Content-Type": "application/json",
"x-api-key": self.api_key,
"app": "dice",
"x-app-var": self.config["client"][self.device]["app_version"],
"Origin": "https://watch.shudder.com",
"Connection": "keep-alive",
})
# Handle credential-based auth (email/password)
if credential:
self.log.info("Authenticating with credentials")
# First get init tokens
init_params = {
"lk": "language",
"pk": "subTitleLanguage,subtitlePreferenceMode,subtitlePreferenceMap,audioLanguage,autoAdvance,pluginAccessTokens,videoBackgroundAutoPlay",
"readLicences": "true",
"countEvents": "LIVE",
"menuTargetPlatform": "WEB",
"readIconStore": "ENABLED",
"readUserProfiles": "true"
}
init_resp = self.session.get(
url=self.config["endpoints"]["init"],
params=init_params
)
init_resp.raise_for_status()
init_data = init_resp.json()
# Login with credentials
login_resp = self.session.post(
url=self.config["endpoints"]["login"],
headers={
"Authorization": f"Bearer {init_data.get('authentication', {}).get('authorisationToken', '')}",
"Realm": self.realm,
},
json={
"id": credential.username,
"secret": credential.password,
}
)
login_resp.raise_for_status()
login_data = login_resp.json()
self.auth_token = login_data.get("authorisationToken")
self.refresh_token = login_data.get("refreshToken")
if not self.auth_token:
raise ValueError("Authentication failed - no token received")
self.session.headers.update({
"Authorization": f"Bearer {self.auth_token}",
"Realm": self.realm,
})
self.log.info("Authentication successful")
return
if cookies:
self.log.info("Authenticating with cookies")
for cookie in cookies:
if cookie.name == "auth_token":
self.auth_token = cookie.value
self.session.headers.update({
"Authorization": f"Bearer {self.auth_token}",
"Realm": self.realm,
})
return
raise ValueError("No valid auth_token cookie found")
raise EnvironmentError("Service requires Credentials or Cookies for Authentication.")
def search(self) -> Generator[SearchResult, None, None]:
"""Search for titles on Shudder"""
search_resp = self.session.get(
url=self.config["endpoints"]["search"],
params={
"query": self.title,
"timezone": self.config.get("timezone", "UTC"),
}
)
search_resp.raise_for_status()
search_data = search_resp.json()
cards = []
for element in search_data.get("elements", []):
if element.get("$type") != "cardList":
continue
cards.extend(element.get("attributes", {}).get("cards", []))
for card in cards:
attrs = card.get("attributes", {})
action = attrs.get("action", {})
route = action.get("data", {}) if action.get("type") == "route" else {}
if not route:
continue
content_type = str(route.get("type", "")).upper()
if content_type not in ("VOD", "SERIES"):
continue
raw_id = str(route.get("id", ""))
if not raw_id:
continue
# "VOD#877410" -> "877410"
# "SERIES#3311" -> "3311"
title_id = raw_id.split("#", 1)[-1].strip()
if not title_id:
continue
is_series = content_type == "SERIES"
yield SearchResult(
id_=title_id,
title=route.get("title", ""),
label="SERIES" if is_series else "MOVIE",
url=f"https://watch.shudder.com/{'series' if is_series else 'watch'}/{title_id}",
)
def _parse_title_input(self) -> tuple[str, Optional[str], Optional[str]]:
"""
Returns:
(title_id, kind, season_id)
kind:
- "watch" for movie/episode URLs like /watch/927436
- "series" for series URLs like /series/3713?seasonId=33510
- None for raw numeric ids
"""
raw = str(self.title).strip()
if raw.isdigit():
return raw, None, None
parsed = urlparse(raw)
if parsed.scheme and parsed.netloc:
parts = [p for p in parsed.path.split("/") if p]
kind = parts[0].lower() if parts else None
title_id = parts[1] if len(parts) > 1 else None
season_id = parse_qs(parsed.query).get("seasonId", [None])[0]
if title_id and title_id.isdigit():
return title_id, kind, season_id
match = re.match(self.TITLE_RE, raw)
if not match:
raise ValueError(f"Invalid Shudder title: {raw}")
return match.group("title_id"), None, None
def _build_manifest_payload(self, video_id: Union[str, int]) -> dict:
return {
"mediaCapabilities": [
{
"protocols": ["HLS", "DASH"],
"audioCodecs": ["aac"],
"videoCodecs": ["h264", "hevc"],
},
{
"keySystem": "WIDEVINE",
"robustness": "software",
"protocols": ["HLS", "DASH"],
"audioCodecs": ["aac"],
"encryptionMode": ["CBC", "CTR"],
"videoCodecs": ["h264"],
},
],
"macros": {
"CM-APP-NAME": "Website",
"CM-APP-VERSION": self.config["client"][self.device]["app_version"],
"CM-DVC-DNT": "0",
"CM-DVC-H": "1200",
"CM-DVC-W": "1920",
"CM-DVC-LANG": "en-US",
"CM-DVC-OS": "14",
"CM-DVC-TYPE": "2",
"CM-WEB-MBL": "0",
"CM-WEB-PAGE": f"/video/{video_id}",
"CM-CST-TCF": "",
"CM-CST-USP": "",
"CM-DVC-ATS": "",
},
}
def _get_video_metadata(self, video_id: str) -> Optional[dict]:
resp = self.session.post(
url=self.config["endpoints"]["video"].format(video_id=video_id),
params={"includePlaybackDetails": "URL", "displayGeoblocked": "HIDE"},
json=self._build_manifest_payload(video_id),
)
if resp.status_code in (404, 405):
return None
resp.raise_for_status()
return resp.json()
def _get_series_view(self, series_id: str, season_id: Optional[str] = None) -> dict:
"""
Supports both config styles:
1. full templated URL:
https://.../api/v1/view?type=series&id={series_id}&timezone=UTC
2. base URL:
https://.../api/v1/view
"""
endpoint = self.config["endpoints"]["series_view"]
params = {}
if "{series_id}" in endpoint:
url = endpoint.format(series_id=series_id)
else:
url = endpoint
params.update({
"type": "series",
"id": series_id,
"timezone": self.config.get("timezone", "UTC"),
})
if season_id:
params["seasonId"] = season_id
resp = self.session.get(url=url, params=params or None)
resp.raise_for_status()
return resp.json()
def _get_series_element(self, data: dict) -> dict:
for element in data.get("elements", []):
if element.get("$type") == "series":
return element.get("attributes", {})
return {}
def _get_season_bucket(self, data: dict) -> dict:
for element in data.get("elements", []):
if element.get("$type") != "bucket":
continue
attrs = element.get("attributes", {})
if attrs.get("tab") == "season" or attrs.get("type") == "season":
return attrs
return {}
def _extract_series_description(self, data: dict) -> str:
for element in data.get("elements", []):
if element.get("$type") != "hero":
continue
for item in element.get("attributes", {}).get("content", []):
if item.get("$type") == "textblock":
text = item.get("attributes", {}).get("text")
if text:
return text
return ""
def _extract_series_year(self, data: dict) -> Optional[int]:
for element in data.get("elements", []):
if element.get("$type") != "hero":
continue
for item in element.get("attributes", {}).get("content", []):
if item.get("$type") != "tagList":
continue
for tag in item.get("attributes", {}).get("tags", []):
text = str(tag.get("attributes", {}).get("text", "")).strip()
if re.fullmatch(r"\d{4}", text):
return int(text)
return None
@staticmethod
def _parse_episode_label(label: str, fallback_number: int) -> tuple[int, str]:
label = (label or "").strip()
if not label:
return fallback_number, f"Episode {fallback_number}"
m = re.match(r"^E(?P<number>\d+)\s*[-:]\s*(?P<name>.+)$", label, re.I)
if m:
return int(m.group("number")), m.group("name").strip()
m = re.match(r"^Episode\s+(?P<number>\d+)\s*[-:]\s*(?P<name>.+)$", label, re.I)
if m:
return int(m.group("number")), m.group("name").strip()
return fallback_number, label
def _get_series_titles(self, series_id: str, preferred_season_id: Optional[str] = None) -> Series:
"""
Important:
The /view response usually contains episode items only for the selected season.
So we fetch the initial page, then request each season explicitly with seasonId=...
"""
page = self._get_series_view(series_id, preferred_season_id)
series_element = self._get_series_element(page)
season_bucket = self._get_season_bucket(page)
metadata = page.get("metadata", {})
series_title = (
metadata.get("pageTitle")
or series_element.get("series", {}).get("title")
or ""
)
series_description = self._extract_series_description(page)
series_year = self._extract_series_year(page)
seasons = series_element.get("seasons", {}).get("items", [])
if not seasons:
raise ValueError(f"No seasons found for series {series_id}")
initial_season_id = str(
season_bucket.get("seasonId")
or season_bucket.get("id")
or series_element.get("seasonId")
or metadata.get("currentSeason", {}).get("seasonId")
or ""
)
cached_items = {}
if initial_season_id:
cached_items[initial_season_id] = season_bucket.get("items", [])
built_episodes = []
seen_episode_ids = set()
for season_index, season in enumerate(seasons, start=1):
season_id = str(season.get("id"))
season_number = season.get("seasonNumber")
if season_number is None:
m = re.search(r"(\d+)", str(season.get("title", "")))
season_number = int(m.group(1)) if m else season_index
else:
season_number = int(season_number)
items = cached_items.get(season_id)
if items is None:
season_page = self._get_series_view(series_id, season_id)
season_bucket = self._get_season_bucket(season_page)
items = season_bucket.get("items", [])
if not items:
self.log.warning(f"No episode items returned for series {series_id}, season {season_number}")
continue
for fallback_ep_num, item in enumerate(items, start=1):
episode_id = str(item["id"])
if episode_id in seen_episode_ids:
continue
seen_episode_ids.add(episode_id)
episode_number, episode_name = self._parse_episode_label(
item.get("title", ""),
fallback_ep_num,
)
built_episodes.append((
season_number,
episode_number,
Episode(
id_=episode_id,
service=self.__class__,
title=series_title,
season=season_number,
number=episode_number,
name=episode_name,
year=series_year,
language=Language.get("en"),
data={
**item,
"series_id": int(series_id),
"series_title": series_title,
"series_description": series_description,
"season_id": season.get("id"),
"season_title": season.get("title"),
"season_number": season_number,
"episode_number": episode_number,
},
),
))
if not built_episodes:
raise ValueError(f"No episodes found for series {series_id}")
return Series([
episode
for _, _, episode in sorted(built_episodes, key=lambda x: (x[0], x[1]))
])
def get_titles(self) -> Titles_T:
"""Get movie or series metadata"""
title_id, kind, season_id = self._parse_title_input()
self.title = title_id
# Explicit /series/... URL -> go straight to series handling
if not self.movie and kind == "series":
return self._get_series_titles(title_id, season_id)
# Try movie/video manifest first
metadata = self._get_video_metadata(title_id)
# If manifest lookup fails, try series view
if metadata is None:
if not self.movie:
self.log.info(f"Manifest lookup failed for {title_id}, trying series view")
return self._get_series_titles(title_id, season_id)
raise ValueError(f"Title {title_id} not found")
if metadata.get("contentDownload", {}).get("permission") == "DISALLOWED":
self.log.warning(f"Download not permitted for title {title_id}")
content_type = str(metadata.get("type", "")).upper()
# Movie path
if self.movie or content_type in ("VOD", "MOVIE"):
return Movies([
Movie(
id_=metadata["id"],
service=self.__class__,
name=metadata.get("title", ""),
description=metadata.get("description", metadata.get("longDescription", "")),
year=int(metadata.get("productionYear", 0)) if metadata.get("productionYear") else None,
language=Language.get("en"),
data=metadata,
)
])
# Direct episode ids are not ideal without the parent series context
if "SEASON" in content_type or "EPISODE" in content_type:
raise ValueError(
"Direct episode IDs are not supported yet. "
"Use the series URL or series id instead."
)
# Fallback to series handling
return self._get_series_titles(title_id, season_id)
def get_tracks(self, title: Title_T) -> Tracks:
"""Fetch and parse manifest tracks"""
def _fetch_variant(
title: Title_T,
codec: Optional[Video.Codec],
range_: Video.Range,
) -> Tracks:
vcodec_str = "hevc" if codec == Video.Codec.HEVC else "h264"
range_str = range_.name
video_format = self.VIDEO_RANGE_MAP.get(range_str, "sdr")
self.log.info(f" + Fetching {vcodec_str.upper()} {range_str} manifest")
# Build media capabilities payload
media_capabilities = [
{
"protocols": ["HLS", "DASH"],
"audioCodecs": ["aac"],
"videoCodecs": [vcodec_str],
}
]
# Add DRM capabilities for encrypted streams
if codec:
media_capabilities.append({
"keySystem": "WIDEVINE",
"robustness": "software",
"protocols": ["HLS", "DASH"],
"audioCodecs": ["aac"],
"encryptionMode": ["CBC", "CTR"],
"videoCodecs": [vcodec_str],
})
# Build macros for request
macros = {
"CM-APP-NAME": "Website",
"CM-APP-VERSION": self.config["client"][self.device]["app_version"],
"CM-DVC-DNT": "0",
"CM-DVC-H": "1080",
"CM-DVC-W": "1920",
"CM-DVC-LANG": "en-US",
"CM-DVC-OS": "14",
"CM-DVC-TYPE": "2",
"CM-WEB-MBL": "0",
f"CM-WEB-PAGE": f"/video/{title.id}",
}
# Inside _fetch_variant() in get_tracks():
manifest_resp = self.session.post(
url=self.config["endpoints"]["manifest"].format(video_id=title.id),
params={"includePlaybackDetails": "URL", "displayGeoblocked": "HIDE"},
json={
"mediaCapabilities": media_capabilities, # Same as above
"macros": macros, # Same as above, update CM-WEB-PAGE with title.id
}
)
manifest_resp.raise_for_status()
manifest_data = manifest_resp.json()
# Extract stream URL and DRM info
streams = manifest_data.get("streams", [])
if not streams:
raise ValueError("No streams available for this title")
stream = streams[0] # Take first available stream
stream_url = stream.get("url")
if not stream_url:
raise ValueError("No stream URL found in manifest")
# Store DRM/license data for later use
drm = stream.get("drm", {})
if drm:
self.license_data = {
"url": drm.get("url", self.config["endpoints"]["widevine_license"]),
"jwtToken": drm.get("jwtToken", ""),
"encryptionMode": drm.get("encryptionMode", "CBC"),
"keySystems": drm.get("keySystems", []),
}
# Parse manifest based on protocol
if "m3u8" in stream_url.lower():
tracks = HLS.from_url(url=stream_url, session=self.session).to_tracks(language=title.language)
else:
tracks = DASH.from_url(url=stream_url, session=self.session).to_tracks(language=title.language)
# Apply video range to tracks
range_enum = {
"hdr10": Video.Range.HDR10,
"dolby_vision": Video.Range.DV,
}.get(video_format, Video.Range.SDR)
for video in tracks.videos:
video.range = range_enum
# Filter audio tracks (remove clear/unencrypted if DRM present)
if drm:
tracks.audio = [
track for track in tracks.audio
if "clear" not in str(track.data).lower()
]
# Fix channel counts
for track in tracks.audio:
if track.channels == 6.0:
track.channels = 5.1
# Check for descriptive audio
label = track.data.get("label", "").lower() if isinstance(track.data, dict) else ""
if "audio description" in label or "descriptive" in label:
track.descriptive = True
return tracks
return self._get_tracks_for_variants(title, _fetch_variant)
def get_chapters(self, title: Title_T) -> list[Chapter]:
"""Extract chapter markers if available"""
chapters = []
# Check for skip markers in title data
skip_markers = title.data.get("skipMarkers", [])
for marker in skip_markers:
marker_type = marker.get("type", "").lower()
start = marker.get("start", marker.get("offset"))
end = marker.get("end")
if marker_type == "intro" and start is not None:
chapters.append(Chapter(timestamp=int(start), name="Opening"))
if end:
chapters.append(Chapter(timestamp=int(end)))
elif marker_type == "credits" and start is not None:
chapters.append(Chapter(timestamp=int(start), name="Credits"))
return chapters
def get_widevine_service_certificate(self, **_: any) -> str:
"""Return Widevine service certificate if configured"""
return self.config.get("certificate", "")
def get_widevine_license(self, *, challenge: bytes, title: Title_T, track: AnyTrack) -> Optional[Union[bytes, str]]:
"""Request Widevine license from Shudder's DRM server"""
license_url = self.license_data.get("url") or self.config["endpoints"]["widevine_license"]
if not license_url:
raise ValueError("Widevine license endpoint not configured")
# Build license request headers
headers = {
"User-Agent": self.config["client"][self.device]["user_agent"],
"Accept": "*/*",
"Accept-Language": "en-US,en;q=0.9",
"Accept-Encoding": "gzip, deflate, br, zstd",
"Origin": "https://watch.shudder.com",
"Referer": "https://watch.shudder.com/",
"Connection": "keep-alive",
"Sec-Fetch-Dest": "empty",
"Sec-Fetch-Mode": "cors",
"Sec-Fetch-Site": "cross-site",
}
# Add DRM info header for Widevine
drm_info = {"system": "com.widevine.alpha"}
headers["X-DRM-INFO"] = base64.b64encode(json.dumps(drm_info).encode()).decode()
# Add authorization if we have JWT token
jwt_token = self.license_data.get("jwtToken")
if jwt_token:
headers["Authorization"] = f"Bearer {jwt_token}"
elif hasattr(self, "auth_token"):
headers["Authorization"] = f"Bearer {self.auth_token}"
# Send license request
response = self.session.post(
url=license_url,
data=challenge,
headers=headers,
)
response.raise_for_status()
# Handle JSON or binary license response
try:
license_data = response.json()
# Shudder may return license in different fields
return license_data.get("license") or license_data.get("data") or response.content
except ValueError:
return response.content

49
SHUD/config.yaml Normal file
View File

@ -0,0 +1,49 @@
# Shudder (SHUD) Configuration
api_key: "857a1e5d-e35e-4fdf-805b-a87b6f8364bf"
endpoints:
# Initialization
init: "https://dce-frontoffice.imggaming.com/api/v1/init/"
# Authentication (with caching support)
login: "https://dce-frontoffice.imggaming.com/api/v2/login"
refresh: "https://dce-frontoffice.imggaming.com/api/v2/token/refresh"
# Content Discovery
search: "https://search.dce-prod.dicelaboratory.com/search"
# Video/Episode Manifest (POST)
video: "https://dce-frontoffice.imggaming.com/api/v5/manifest/video/{video_id}"
manifest: "https://dce-frontoffice.imggaming.com/api/v5/manifest/video/{video_id}"
# Series Metadata
series_view: "https://dce-frontoffice.imggaming.com/api/v1/view?type=series&id={series_id}&timezone=UTC"
# DRM License Servers
widevine_license: "https://shield-drm.imggaming.com/api/v2/license"
playready_license: "https://shield-drm.imggaming.com/api/v2/license"
client:
web:
user_agent: "Mozilla/5.0 (X11; Linux x86_64; rv:149.0) Gecko/20100101 Firefox/149.0"
license_user_agent: "Mozilla/5.0 (X11; Linux x86_64; rv:149.0) Gecko/20100101 Firefox/149.0"
app_version: "6.60.0.7cf91e1"
type: "BROWSER"
android_tv:
user_agent: "okhttp/4.12.0"
license_user_agent: "okhttp/4.12.0"
app_version: "6.60.0"
type: "ANDROID_TV"
# Auth token cache duration (seconds)
auth_cache_duration: 3600
# Optional: Widevine certificate
# certificate: "CAUSxwE..."
realm: "dce.shudder"
language: "en_US"
rate_limit: 2
session_timeout: 300

View File

@ -697,18 +697,14 @@ class SKST(Service):
protection = playback_data.get("protection", {})
self.drm_license_url = protection.get("licenceAcquisitionUrl")
self.license_token = protection.get("licenceToken")
self.license_token = protection.get("licenceToken")
manifest_url = manifest_url + "&audio=all&subtitle=all"
dash = DASH.from_url(manifest_url, session=self.session)
tracks = dash.to_tracks(language=title.language)
# Remove default subtitle tracks and add properly processed ones
for track in list(tracks.subtitles):
tracks.subtitles.remove(track)
subtitles = self._process_subtitles(dash, str(title.language))
tracks.add(subtitles)
return tracks
@staticmethod
@ -1045,4 +1041,4 @@ class SKST(Service):
# )
def get_chapters(self, title: Title_T) -> list[Chapter]:
return []
return []

653
VLD/__init__.py Normal file
View File

@ -0,0 +1,653 @@
import re
import uuid
from collections.abc import Generator
from http.cookiejar import CookieJar
from typing import Optional, Union
import click
from langcodes import Language
from unshackle.core.constants import AnyTrack
from unshackle.core.credential import Credential
from unshackle.core.manifests import DASH
from unshackle.core.search_result import SearchResult
from unshackle.core.service import Service
from unshackle.core.titles import Episode, Movie, Movies, Series, Title_T, Titles_T
from unshackle.core.tracks import Chapter, Subtitle, Tracks
class VLD(Service):
"""
Service code for RTL's Dutch streaming service Videoland (https://v2.videoland.com)
Version: 1.1.0
Authorization: Credentials
Security:
- L1: >= 720p
- L3: <= 576p
They are using the license server of DRMToday with encoded streams from CastLabs.
It accepts Non-Whitelisted CDMs so every unrevoked L1 CDM should work.
Use full URL (for example - https://v2.videoland.com/title-p_12345) or title slug.
"""
ALIASES = ("VLD", "videoland")
TITLE_RE = r"^(?:https?://(?:www\.)?v2\.videoland\.com/)?(?P<title_id>[a-zA-Z0-9_-]+)"
GEOFENCE = ("NL",)
@staticmethod
@click.command(name="Videoland", short_help="https://v2.videoland.com")
@click.argument("title", type=str)
@click.option("-m", "--movie", is_flag=True, default=False, help="Specify if it's a movie")
@click.pass_context
def cli(ctx, **kwargs):
return VLD(ctx, **kwargs)
def __init__(self, ctx, title, movie):
super().__init__(ctx)
self.title = title
self.movie = movie
self.cdm = ctx.obj.cdm
self.device_id = str(uuid.uuid1().int)
if self.config is None:
raise Exception("Config is missing!")
profile_name = ctx.parent.params.get("profile")
self.profile = profile_name if profile_name else "default"
self.platform = self.config["platform"]["android_tv"]
self.platform_token = "token-androidtv-3"
# Auth state - initialized to None, populated by authenticate()
self.access_token = None
self.gigya_uid = None
self.profile_id = None
def authenticate(self, cookies: Optional[CookieJar] = None, credential: Optional[Credential] = None) -> None:
super().authenticate(cookies, credential)
if not credential or not credential.username or not credential.password:
raise EnvironmentError("Service requires Credentials for Authentication.")
self.credential = credential
self.session.headers.update({
"origin": "https://v2.videoland.com",
"x-client-release": self.config["sdk"]["version"],
"x-customer-name": "rtlnl",
})
cache_key = f"tokens_{credential.username}"
cache = self.cache.get(cache_key)
if cache and not cache.expired:
cached_data = cache.data
if (
isinstance(cached_data, dict)
and cached_data.get("username") == credential.username
and cached_data.get("access_token")
and cached_data.get("gigya_uid")
and cached_data.get("profile_id")
):
self.log.info("Using cached Videoland tokens")
self._restore_from_cache(cached_data)
return
else:
self.log.warning("Cached token data is incomplete or mismatched, re-authenticating")
self.log.info("Retrieving new Videoland tokens")
self._do_login(credential)
self._cache_tokens(credential.username, cache)
def _invalidate_cache(self) -> None:
"""Wipe the cached tokens for the current credential so the next
call to authenticate() is forced to perform a fresh login."""
if not self.credential:
return
cache_key = f"tokens_{self.credential.username}"
cache = self.cache.get(cache_key)
# Writing an empty dict with a TTL of 0 effectively expires it
# immediately so the next cache.expired check returns True.
try:
cache.set(data={}, expiration=0)
self.log.debug("Token cache invalidated")
except Exception:
pass # If the cache backend refuses, just continue
def _reauthenticate(self) -> None:
"""Invalidate the cache and perform a completely fresh login.
Call this whenever the API returns a token-expired error so that
the rest of the current run continues with valid credentials.
"""
self.log.warning("Access token has expired — invalidating cache and re-authenticating")
self._invalidate_cache()
self._do_login(self.credential)
# Re-persist the brand-new tokens
cache_key = f"tokens_{self.credential.username}"
cache = self.cache.get(cache_key)
self._cache_tokens(self.credential.username, cache)
def _restore_from_cache(self, cached_data: dict) -> None:
"""Restore authentication state from cached data."""
self.access_token = cached_data["access_token"]
self.gigya_uid = cached_data["gigya_uid"]
self.profile_id = cached_data["profile_id"]
self.session.headers.update({"Authorization": f"Bearer {self.access_token}"})
def _cache_tokens(self, username: str, cache: object) -> None:
"""Persist the current tokens into the cache object.
Accepts the cache object directly instead of re-fetching it by key,
so we always write to the exact same object we checked during the
cache-hit test in authenticate().
"""
cache.set(
data={
"username": username,
"access_token": self.access_token,
"gigya_uid": self.gigya_uid,
"profile_id": self.profile_id,
},
# 3500 seconds gives a 100-second safety margin below the
# typical 1-hour JWT lifetime so we never use a nearly-expired token.
expiration=3500,
)
self.log.info("Videoland tokens cached successfully")
def _do_login(self, credential: Credential) -> None:
"""Perform the full four-step Videoland / Gigya login flow."""
# ── Step 1: Gigya account login ──────────────────────────────
auth_response = self.session.post(
url=self.config["endpoints"]["authorization"],
data={
"loginID": credential.username,
"password": credential.password,
"sessionExpiration": "0",
"targetEnv": "jssdk",
"include": "profile,data",
"includeUserInfo": "true",
"lang": "nl",
"ApiKey": self.config["sdk"]["apikey"],
"authMode": "cookie",
"pageURL": "https://v2.videoland.com/",
"sdkBuild": self.config["sdk"]["build"],
"format": "json",
},
).json()
if auth_response.get("errorMessage"):
raise EnvironmentError(
f"Could not authorize Videoland account: {auth_response['errorMessage']!r}"
)
self.gigya_uid = auth_response["UID"]
uid_signature = auth_response["UIDSignature"]
signature_timestamp = auth_response["signatureTimestamp"]
# ── Step 2: Exchange Gigya credentials for an initial JWT ─────
jwt_headers = {
"x-auth-device-id": self.device_id,
"x-auth-device-player-size-height": "3840",
"x-auth-device-player-size-width": "2160",
"X-Auth-gigya-signature": uid_signature,
"X-Auth-gigya-signature-timestamp": signature_timestamp,
"X-Auth-gigya-uid": self.gigya_uid,
"X-Client-Release": self.config["sdk"]["version"],
"X-Customer-Name": "rtlnl",
}
jwt_response = self.session.get(
url=self.config["endpoints"]["jwt_tokens"].format(platform=self.platform),
headers=jwt_headers,
).json()
if jwt_response.get("error"):
raise EnvironmentError(
f"Could not get Access Token: {jwt_response['error']['message']!r}"
)
initial_token = jwt_response["token"]
# ── Step 3: Fetch profiles and pick the first one ─────────────
profiles_response = self.session.get(
url=self.config["endpoints"]["profiles"].format(
platform=self.platform,
gigya=self.gigya_uid,
),
headers={"Authorization": f"Bearer {initial_token}"},
).json()
if isinstance(profiles_response, dict) and profiles_response.get("error"):
raise EnvironmentError(
f"Could not get profiles: {profiles_response['error']['message']!r}"
)
self.profile_id = profiles_response[0]["uid"]
# ── Step 4: Obtain a profile-scoped JWT (the final token) ─────
jwt_headers["X-Auth-profile-id"] = self.profile_id
final_jwt_response = self.session.get(
url=self.config["endpoints"]["jwt_tokens"].format(platform=self.platform),
headers=jwt_headers,
).json()
if final_jwt_response.get("error"):
raise EnvironmentError(
f"Could not get final Access Token: {final_jwt_response['error']['message']!r}"
)
self.access_token = final_jwt_response["token"]
self.session.headers.update({"Authorization": f"Bearer {self.access_token}"})
# ------------------------------------------------------------------
# Title discovery
# ------------------------------------------------------------------
def search(self) -> Generator[SearchResult, None, None]:
query = self.title.strip()
if not query:
return
response = self.session.post(
url=self.config["endpoints"]["search"],
params={
"x-algolia-agent": self.config["algolia"]["agent"],
"x-algolia-api-key": self.config["algolia"]["api_key"],
"x-algolia-application-id": self.config["algolia"]["app_id"],
},
headers={
"Accept": "application/json",
"Content-Type": "text/plain",
"Referer": "https://v2.videoland.com/",
"Origin": "https://v2.videoland.com",
},
json={
"requests": [
{
"indexName": self.config["algolia"]["index"],
"query": query,
"clickAnalytics": True,
"hitsPerPage": 50,
"facetFilters": [
["metadata.item_type:program"],
[f"metadata.platforms_assets:{self.config['platform']['web']}"],
],
}
]
},
)
response.raise_for_status()
data = response.json()
results = data.get("results", [])
if not results:
return
seen = set()
for hit in results[0].get("hits", []):
metadata = hit.get("metadata", {}) or {}
item = hit.get("item", {}) or {}
item_content = item.get("itemContent", {}) or {}
target = (
item_content.get("action", {})
.get("target", {})
.get("value_layout", {})
)
content = hit.get("content", {}) or {}
content_id = str(target.get("id") or content.get("id") or "").strip()
seo = target.get("seo")
title = item_content.get("title") or metadata.get("title")
if not content_id or not title:
continue
if content_id in seen:
continue
seen.add(content_id)
edito_tags = metadata.get("tags", {}).get("edito", []) or []
program_nature = metadata.get("tags", {}).get("program_nature", []) or []
if "CONTENTTYPE:Film" in edito_tags:
label = "MOVIE"
elif "CONTENTTYPE:Series" in edito_tags:
label = "SERIES"
elif "Unitary" in program_nature:
label = "MOVIE"
else:
label = "SERIES"
url = f"https://v2.videoland.com/{seo}-p_{content_id}" if seo else None
yield SearchResult(
id_=content_id,
title=title,
label=label,
url=url,
)
def get_titles(self) -> Titles_T:
title_match = re.match(self.TITLE_RE, self.title)
if not title_match:
raise ValueError(f"Invalid title format: {self.title}")
title_slug = title_match.group("title_id")
if re.match(r".+?-f_[0-9]+", title_slug):
title_slug = self._get_program_title(title_slug)
title_id = title_slug.split("-p_")[-1] if "-p_" in title_slug else title_slug
metadata = self.session.get(
url=self.config["endpoints"]["layout"].format(
platform=self.platform,
token=self.platform_token,
endpoint=f"program/{title_id}",
),
params={"nbPages": "10"},
).json()
# ── Token expiry detection and automatic recovery ─────────────────
if isinstance(metadata, dict) and metadata.get("error"):
message = metadata.get("message", "Unknown error")
# The API returns "Token expired/invalid" when the JWT has lapsed.
# Re-authenticate once and retry the same request rather than
# crashing with a ValueError.
if "token" in message.lower() and (
"expired" in message.lower() or "invalid" in message.lower()
):
self._reauthenticate()
# Retry the metadata request with the fresh token
metadata = self.session.get(
url=self.config["endpoints"]["layout"].format(
platform=self.platform,
token=self.platform_token,
endpoint=f"program/{title_id}",
),
params={"nbPages": "10"},
).json()
# If it still fails after re-auth, raise normally
if isinstance(metadata, dict) and metadata.get("error"):
raise ValueError(
f"API Error after re-authentication: {metadata.get('message', 'Unknown error')}"
)
else:
raise ValueError(f"API Error: {message}")
is_movie = "Seizoen" not in str(metadata)
if is_movie:
movie_info = metadata["blocks"][0]["content"]["items"][0]
viewable_id = movie_info["itemContent"]["action"]["target"]["value_layout"]["id"]
return Movies([
Movie(
id_=movie_info["ucid"],
service=self.__class__,
name=metadata["entity"]["metadata"]["title"],
year=None,
language=Language.get("nl"),
data={
"viewable": viewable_id,
"metadata": metadata,
},
)
])
seasons = [
block
for block in metadata["blocks"]
if block["featureId"] == "videos_by_season_by_program"
]
for season in seasons:
while (
len(season["content"]["items"])
!= season["content"]["pagination"]["totalItems"]
):
season_data = self.session.get(
url=self.config["endpoints"]["seasoning"].format(
platform=self.platform,
token=self.platform_token,
program=title_id,
season_id=season["id"],
),
params={
"nbPages": "10",
"page": season["content"]["pagination"]["nextPage"],
},
).json()
for episode in season_data["content"]["items"]:
if episode not in season["content"]["items"]:
season["content"]["items"].append(episode)
season["content"]["pagination"]["nextPage"] = (
season_data["content"]["pagination"]["nextPage"]
)
episodes = []
for season in seasons:
season_title = season.get("title", {}).get("long", "")
season_match = re.search(r"(\d+)", season_title)
season_number = int(season_match.group(1)) if season_match else 1
for idx, episode_data in enumerate(season["content"]["items"]):
extra_title = episode_data["itemContent"].get("extraTitle", "")
episode_number = None
episode_name = extra_title
ep_match = re.match(r"^(\d+)\.\s*(.*)$", extra_title)
if ep_match:
episode_number = int(ep_match.group(1))
episode_name = ep_match.group(2)
else:
episode_number = idx + 1
viewable_id = (
episode_data["itemContent"]["action"]["target"]["value_layout"]["id"]
)
episodes.append(
Episode(
id_=episode_data["ucid"],
service=self.__class__,
title=metadata["entity"]["metadata"]["title"],
season=season_number,
number=episode_number,
name=episode_name,
year=None,
language=Language.get("nl"),
data={
"viewable": viewable_id,
"episode_data": episode_data,
},
)
)
episodes = sorted(episodes, key=lambda ep: (ep.season, ep.number))
return Series(episodes)
def get_tracks(self, title: Title_T) -> Tracks:
viewable_id = title.data["viewable"]
manifest_response = self.session.get(
url=self.config["endpoints"]["layout"].format(
platform=self.platform,
token=self.platform_token,
endpoint=f"video/{viewable_id}",
),
params={"nbPages": "2"},
).json()
# ── Token expiry detection in get_tracks ──────────────────────────
if isinstance(manifest_response, dict) and manifest_response.get("error"):
message = manifest_response.get("message", "Unknown error")
if "token" in message.lower() and (
"expired" in message.lower() or "invalid" in message.lower()
):
self._reauthenticate()
manifest_response = self.session.get(
url=self.config["endpoints"]["layout"].format(
platform=self.platform,
token=self.platform_token,
endpoint=f"video/{viewable_id}",
),
params={"nbPages": "2"},
).json()
if isinstance(manifest_response, dict) and manifest_response.get("error"):
raise ValueError(
f"API Error after re-authentication: {manifest_response.get('message', 'Unknown error')}"
)
else:
raise ValueError(f"API Error: {message}")
player_block = next(
(
block
for block in manifest_response["blocks"]
if block["templateId"] == "Player"
),
None,
)
if not player_block:
raise ValueError("Could not find player block in manifest")
assets = player_block["content"]["items"][0]["itemContent"]["video"]["assets"]
if not assets:
raise ValueError("Failed to load content manifest - no assets found")
mpd_asset = next((a for a in assets if a["quality"] == "hd"), None) or \
next((a for a in assets if a["quality"] == "sd"), None)
if not mpd_asset:
raise ValueError("No suitable quality stream found")
mpd_url = mpd_asset["path"]
tracks = DASH.from_url(url=mpd_url, session=self.session).to_tracks(
language=title.language
)
for track in tracks:
if not hasattr(track, "url") or not track.url:
continue
if isinstance(track.url, list):
track.url = [
re.sub(
r"https://.+?\.videoland\.bedrock\.tech",
"https://origin.vod.videoland.bedrock.tech",
uri.split("?")[0],
)
for uri in track.url
]
elif isinstance(track.url, str):
track.url = re.sub(
r"https://.+?\.videoland\.bedrock\.tech",
"https://origin.vod.videoland.bedrock.tech",
track.url.split("?")[0],
)
for subtitle in tracks.subtitles:
url_str = str(subtitle.url) if subtitle.url else ""
if "sdh" in url_str.lower():
subtitle.sdh = True
if "forced" in url_str.lower() or "opencaption" in url_str.lower():
subtitle.forced = True
self.log.info(
f"Tracks: {len(tracks.videos)} video, "
f"{len(tracks.audio)} audio, "
f"{len(tracks.subtitles)} subtitle"
)
self.current_viewable = viewable_id
return tracks
def get_chapters(self, title: Title_T) -> list[Chapter]:
return []
def get_widevine_service_certificate(self, **_) -> Optional[str]:
return self.config.get("certificate")
def get_widevine_license(
self, *, challenge: bytes, title: Title_T, track: AnyTrack
) -> Optional[Union[bytes, str]]:
license_token = self._get_license_token(title)
response = self.session.post(
url=self.config["endpoints"]["license_wv"],
data=challenge,
headers={"x-dt-auth-token": license_token},
)
if response.status_code != 200:
raise ValueError(f"Failed to get Widevine license: {response.status_code}")
return response.json().get("license")
def get_playready_license(
self, *, challenge: bytes, title: Title_T, track: AnyTrack
) -> Optional[bytes]:
license_token = self._get_license_token(title)
response = self.session.post(
url=self.config["endpoints"]["license_pr"],
data=challenge,
headers={"x-dt-auth-token": license_token},
)
if response.status_code != 200:
raise ValueError(f"Failed to get PlayReady license: {response.status_code}")
return response.content
# ------------------------------------------------------------------
# Private helpers
# ------------------------------------------------------------------
def _get_license_token(self, title: Title_T) -> str:
"""Fetch a per-clip DRM upfront token from the Videoland token endpoint."""
viewable_id = title.data["viewable"]
response = self.session.get(
url=self.config["endpoints"]["license_token"].format(
platform=self.platform,
gigya=self.gigya_uid,
clip=viewable_id,
),
).json()
return response["token"]
def _get_program_title(self, folder_title: str) -> str:
"""Resolve a folder slug (title-f_12345) to its programme slug (title-p_12345)."""
folder_id = folder_title.split("-f_")[1]
response = self.session.get(
url=self.config["endpoints"]["layout"].format(
platform=self.platform,
token=self.platform_token,
endpoint=f"folder/{folder_id}",
),
params={"nbPages": "2"},
).json()
target = response["blocks"][0]["content"]["items"][0]["itemContent"]["action"][
"target"
]["value_layout"]
parent_seo = target["parent"]["seo"]
parent_id = target["parent"]["id"]
return f"{parent_seo}-p_{parent_id}"

36
VLD/config.yaml Normal file
View File

@ -0,0 +1,36 @@
certificate: |
CsECCAMSEBcFuRfMEgSGiwYzOi93KowYgrSCkgUijgIwggEKAoIBAQCZ7Vs7Mn2rXiTvw7YqlbWYUgrVvMs3UD4GRbgU2Ha430BRBEGtjOOtsRu4jE5yWl5
KngeVKR1YWEAjp+GvDjipEnk5MAhhC28VjIeMfiG/+/7qd+EBnh5XgeikX0YmPRTmDoBYqGB63OBPrIRXsTeo1nzN6zNwXZg6IftO7L1KEMpHSQykfqpdQ4
IY3brxyt4zkvE9b/tkQv0x4b9AsMYE0cS6TJUgpL+X7r1gkpr87vVbuvVk4tDnbNfFXHOggrmWEguDWe3OJHBwgmgNb2fG2CxKxfMTRJCnTuw3r0svAQxZ6
ChD4lgvC2ufXbD8Xm7fZPvTCLRxG88SUAGcn1oJAgMBAAE6FGxpY2Vuc2Uud2lkZXZpbmUuY29tEoADrjRzFLWoNSl/JxOI+3u4y1J30kmCPN3R2jC5MzlR
HrPMveoEuUS5J8EhNG79verJ1BORfm7BdqEEOEYKUDvBlSubpOTOD8S/wgqYCKqvS/zRnB3PzfV0zKwo0bQQQWz53ogEMBy9szTK/NDUCXhCOmQuVGE98K/
PlspKkknYVeQrOnA+8XZ/apvTbWv4K+drvwy6T95Z0qvMdv62Qke4XEMfvKUiZrYZ/DaXlUP8qcu9u/r6DhpV51Wjx7zmVflkb1gquc9wqgi5efhn9joLK3
/bNixbxOzVVdhbyqnFk8ODyFfUnaq3fkC3hR3f0kmYgI41sljnXXjqwMoW9wRzBMINk+3k6P8cbxfmJD4/Paj8FwmHDsRfuoI6Jj8M76H3CTsZCZKDJjM3B
QQ6Kb2m+bQ0LMjfVDyxoRgvfF//M/EEkPrKWyU2C3YBXpxaBquO4C8A0ujVmGEEqsxN1HX9lu6c5OMm8huDxwWFd7OHMs3avGpr7RP7DUnTikXrh6X0
endpoints:
layout: https://layout.videoland.bedrock.tech/front/v1/rtlnl/{platform}/main/{token}/{endpoint}/layout
seasoning: https://layout.videoland.bedrock.tech/front/v1/rtlnl/{platform}/main/{token}/program/{program}/block/{season_id}
license_pr: https://lic.drmtoday.com/license-proxy-headerauth/drmtoday/RightsManager.asmx
license_wv: https://lic.drmtoday.com/license-proxy-widevine/cenc/
license_token: https://drm.videoland.bedrock.tech/v1/customers/rtlnl/platforms/{platform}/services/videoland/users/{gigya}/videos/{clip}/upfront-token
authorization: https://accounts.eu1.gigya.com/accounts.login
jwt_tokens: https://front-auth.videoland.bedrock.tech/v2/platforms/{platform}/getJwt
profiles: https://users.videoland.bedrock.tech/v2/platforms/{platform}/users/{gigya}/profiles
search: https://nhacvivxxk-dsn.algolia.net/1/indexes/*/queries
platform:
web: m6group_web
android_mob: m6group_android_mob
android_tv: m6group_android_tv
algolia:
app_id: NHACVIVXXK
api_key: 6ef59fc6d78ac129339ab9c35edd41fa
agent: Algolia for JavaScript (5.49.1); Search (5.49.1); Browser
index: videoland_prod_bedrock_layout_items_v2_rtlnl_main
sdk:
apikey: 3_W6BPwMz2FGQEfH4_nVRaj4Ak1F1XDp33an_8y8nXULn8nk43FHvPIpb0TLOYIaUI
build: "13414"
version: 5.47.2