upstream services

This commit is contained in:
VineFeeder
2026-05-01 11:17:28 +01:00
parent 3624bb0c5d
commit 1812150f21
6 changed files with 982 additions and 371 deletions
@@ -18,7 +18,7 @@ tag_imdb_tmdb: true
set_terminal_bg: false
# Custom output templates for filenames
# Configure output_template in your unshackle.yaml to control filename format.
# Configure output_template in your envied.yaml to control filename format.
# If not configured, default scene-style templates are used and a warning is shown.
# Available variables: {title}, {year}, {season}, {episode}, {season_episode}, {episode_name},
# {quality}, {resolution}, {source}, {audio}, {audio_channels}, {audio_full},
@@ -404,7 +404,7 @@ headers:
filenames:
debug_log: "unshackle_debug_{service}_{time}.jsonl" # JSON Lines debug log file
config: "config.yaml"
root_config: "unshackle.yaml"
root_config: "envied.yaml"
chapters: "Chapters_{title}_{random}.txt"
subtitle: "Subtitle_{id}_{language}.srt"
@@ -117,6 +117,7 @@ key_vaults:
#- type: SQLite
# name: Local vault
# path: key_store.db
- type: HTTPAPI
name: drmlab
host: http://api.drmlab.io/vault/
@@ -0,0 +1,605 @@
import base64
import hashlib
import json
import re
from concurrent.futures import ThreadPoolExecutor, as_completed
from http.cookiejar import CookieJar
from typing import Any
import click
from envied.core.credential import Credential
from envied.core.manifests import DASH
from envied.core.service import Service
from envied.core.session import session
from envied.core.titles import Episode, Movie, Movies, Series
from envied.core.tracks import Audio, Chapter, Chapters, Subtitle, Tracks
from envied.core.cdm.detect import is_playready_cdm
class CRAV(Service):
"""
Service code for Bell Media's Crave streaming service (https://crave.ca).
\b
Version: 1.0.0
Author: stabbedbybrick
Authorization: Credentials
Geofence: CA (API and downloads)
Robustness:
Widevine:
L1: 1080p, 2160p
L3: 720p
PlayReady:
SL150: 2160p
\b
Tips:
- Input examples:
SERIES: https://www.crave.ca/en/series/it-welcome-to-derry-58324
EPISODE: https://www.crave.ca/en/play/it-welcome-to-derry/the-pilot-s1e1-3208281
MOVIE: https://www.crave.ca/en/movie/superman-2025-58844
\b
Notes:
- Subtitles are not always consistent, so both external (VTT) and internal (WVTT) subtitles are added.
If you experience incomplete subtitles, try using a newer version of Unshackle (v5.0.0+).
- Authentication will look for master profile first, then first profile with adult maturity scope.
- Account pins are currently not supported.
"""
ALIASES = ("crave",)
GEOFENCE = ("ca",)
TITLE_RE = r"^(?:https?://(?:www\.)?crave\.ca(?:/[a-z]{2})?/(?P<type>movie|series|play)/(?:[a-z0-9-]+/)?)?([a-z0-9-]+?)(?:-(?P<id>\d+))?$"
@staticmethod
@click.command(name="CRAV", short_help="https://crave.ca")
@click.argument("title", type=str)
@click.pass_context
def cli(ctx: click.Context, **kwargs: Any) -> "CRAV":
return CRAV(ctx, **kwargs)
def __init__(self, ctx, title):
super().__init__(ctx)
self.title = title
self.cdm = ctx.obj.cdm
self.drm_system = "playready" if is_playready_cdm(self.cdm) else "widevine"
self.user_profile = ctx.parent.params.get("profile")
if not self.user_profile:
self.user_profile = "default"
def get_session(self) -> session:
return session()
def authenticate(self, cookies: CookieJar | None = None, credential: Credential | None = None) -> None:
if not credential:
raise EnvironmentError("Service requires Credentials for Authentication.")
self.session.headers.update(self.config["headers"])
cache = self.cache.get(f"tokens_{self.user_profile}_{credential.sha1}")
if cache and not cache.expired:
# cached
self.log.info(" + Using cached tokens...")
profile = cache.data
elif cache and cache.expired:
# expired
self.log.info(" + Authenticating...")
profile = cache.data
r = self.session.post(
self.config["endpoints"]["login"],
headers={"authorization": f"Basic {self.config['auth']['android']}"},
data={
"grant_type": "password",
"username": credential.username,
"password": credential.password,
},
)
if not r.ok:
raise ValueError(f"Failed to acquire tokens: {r.text}")
tokens = r.json()
profile["access_token"] = tokens["access_token"]
profile["refresh_token"] = tokens["refresh_token"]
cache.set(profile, expiration=tokens["expires_in"] - 30)
else:
# new
self.log.info(" + Authenticating...")
r = self.session.post(
self.config["endpoints"]["login"],
headers={"authorization": f"Basic {self.config['auth']['android']}"},
data={
"grant_type": "password",
"username": credential.username,
"password": credential.password,
},
)
if not r.ok:
raise ValueError(f"Failed to acquire tokens: {r.text}")
tokens = r.json()
account = self._get_account(tokens["access_token"])
if not account.get("status", "").lower() == "active":
raise ValueError("Account is not active. Please register first.")
account_id = account.get("id")
profile = self._get_profile(account_id, tokens["access_token"])
profile_id = next((p.get("id") for p in profile if p.get("master") or p.get("maturity") == "ADULT"), None)
profile = {
"access_token": tokens["access_token"],
"refresh_token": tokens["refresh_token"],
"account_id": account_id,
"profile_id": profile_id,
"profile_pin": "",
}
cache.set(profile, expiration=tokens["expires_in"] - 30)
# Always refresh profile tokens
self.log.info(" + Refreshing profile tokens...")
r = self.session.post(
self.config["endpoints"]["login"],
headers={"authorization": f"Basic {self.config['auth']['android']}"},
data={
"grant_type": "refresh_token",
"refresh_token": profile["refresh_token"],
"profile_id": profile["profile_id"],
"profile_pin": "",
},
)
if not r.ok:
raise ValueError(f"Failed to refresh tokens: {r.text}")
tokens = r.json()
data = {
"platform": "platform_androidtv",
"accessToken": f"{tokens['access_token']}"
}
self.graphql_token = base64.b64encode(json.dumps(data).encode()).decode()
self.jwt = tokens["access_token"]
self.session.headers.update({"authorization": f"Bearer {self.jwt}"})
def get_titles(self):
if not (match := re.match(self.TITLE_RE, self.title)):
raise ValueError(f"Unable to parse title ID {self.title!r}")
entity_type, content_id = (match.group(i) for i in ("type", "id"))
if entity_type == "series":
episodes = self._get_series(content_id)
return Series(episodes)
elif entity_type == "movie":
movie = self._get_movie(content_id)
return Movies(movie)
elif entity_type == "play":
episode = self._get_episode(content_id)
return Series(episode)
def get_tracks(self, title: Movie | Episode) -> Tracks:
r = self.session.get(
url=self.config["endpoints"]["contents"].format(title.id),
headers={
"X-Client-Platform": "platform_jasper_androidtv",
"X-Playback-Language": title.language.language,
},
)
if not r.ok:
raise ValueError(f"Failed to get content packages: {r.text}")
data = r.json()
title.data["chapters"] = data.get("contentPackage", {}).get("breaks")
if not (package_id := data.get("contentPackage", {}).get("id")):
raise ValueError(f"Failed to get package ID: {data}")
if not (destination_id := data.get("contentPackage", {}).get("destinationId")):
raise ValueError(f"Failed to get destination ID: {data}")
title.data["package_id"] = package_id
title.data["destination_id"] = destination_id
params = {
"format": "mpd",
"filter": "ff",
"uhd": "true",
"hd": "true",
"mcv": "true",
"mca": "true",
"mta": "true",
"stt": "true",
}
r = self.session.get(
url=self.config["endpoints"]["destination"].format(title.id, package_id, destination_id),
params=params,
)
if not r.ok:
raise ValueError(f"Failed to get content packages: {r.text}")
data = r.json()
manifest, subtitles = self._get_assets(data)
tracks = DASH.from_url(manifest, self.session).to_tracks(title.language)
if subtitles is not None:
tracks.add(
Subtitle(
id_=hashlib.md5(subtitles.encode()).hexdigest()[0:6],
url=subtitles,
codec=Subtitle.Codec.WebVTT,
language=title.language,
forced=False,
sdh=True,
)
)
for track in tracks:
if isinstance(track, Audio):
role = track.data["dash"]["adaptation_set"].find("Role")
if role is not None and role.get("value").lower() in ["description", "descriptive", "alternate"]:
track.descriptive = True
track.is_original_lang = track.language == title.language
track.name = "Original" if track.is_original_lang else None
elif isinstance(track, Subtitle):
track.is_original_lang = track.language == title.language
track.name = "Original" if track.is_original_lang else None
if not track.forced:
track.sdh = True
return tracks
def get_chapters(self, title: Episode | Movie) -> Chapters:
if not (cues := title.data.get("chapters")):
return Chapters()
chapters = []
for chapter in cues:
chapters.append(Chapter(name=chapter.get("breakType"), timestamp=int(chapter.get("startTime")) * 1000))
if chapter.get("endTime"):
chapters.append(Chapter(timestamp=int(chapter.get("endTime")) * 1000))
if not any(c.timestamp == "00:00:00.000" for c in chapters):
chapters.append(Chapter(timestamp=0))
return sorted(chapters, key=lambda x: x.timestamp)
def get_widevine_service_certificate(self, challenge: bytes, **_: Any) -> bytes | None:
if self.drm_system == "widevine":
return self.session.post(url=self.license_url, data=challenge).content
return None
def get_widevine_license(self, *, challenge: bytes, title: Episode | Movie, track: Any, **kwargs) -> bytes | str | None:
headers = {
"Accept": "*/*",
"Referer": "https://www.crave.ca/",
"Origin": "https://www.crave.ca",
"Content-Type": "application/x-www-form-urlencoded",
}
data = {
"payload": base64.b64encode(challenge).decode("utf-8"),
"playbackContext": {
"contentId": int(title.id),
"contentpackageId": title.data["package_id"],
"platformId": 1,
"destinationId": title.data["destination_id"],
"gl": "0",
"jwt": self.jwt
}
}
r = self.session.post(
url=self.config["endpoints"]["widevine"],
headers=headers,
data=json.dumps(data),
)
if not r.ok:
raise ValueError(f"Failed to get license: {r.text}")
return r.content
def get_playready_license(self, *, challenge: str, title: Episode | Movie, track: Any, **kwargs) -> bytes | str | None:
headers = {
"Accept": "*/*",
"Referer": "https://www.crave.ca/",
"Origin": "https://www.crave.ca",
"Content-Type": "application/x-www-form-urlencoded",
}
data = {
"payload": base64.b64encode(challenge.encode("utf-8")).decode("utf-8"),
"playbackContext": {
"contentId": int(title.id),
"contentpackageId": title.data["package_id"],
"platformId": 1,
"destinationId": title.data["destination_id"],
"gl": "0",
"jwt": self.jwt
}
}
r = self.session.post(
url=self.config["endpoints"]["playready"],
headers=headers,
data=json.dumps(data),
)
if not r.ok:
raise ValueError(f"Failed to get license: {r.text}")
return r.content
# service-specific methods
def _get_assets(self, data: dict) -> tuple:
manifest, trickplay = data.get("playback"), data.get("trickplay", "")
if not manifest:
raise ValueError(f"Failed to get manifest URL: {data}")
text_url = trickplay.replace("jpeg", "text")
subtitles = text_url if self.session.head(text_url).ok else None
if not subtitles:
self.log.warning("No external subtitle URL available")
# Shouldn't be needed with android auth, but just in case
if "zultimate" not in manifest:
manifest = re.sub(r"zbest-\w+", "zultimate-11110101", manifest)
if self.drm_system == "playready":
manifest = manifest.replace("widevine", "playready")
return manifest, subtitles
def _get_account(self, access_token: str) -> dict:
r = self.session.get(
url=self.config["endpoints"]["account"],
headers={"authorization": f"Bearer {access_token}"},
)
if not r.ok:
raise ValueError(f"Failed to get account: {r.text}")
return r.json()
def _get_profile(self, account_id: str, access_token: str) -> dict:
r = self.session.get(
url=self.config["endpoints"]["profile"].format(account_id),
headers={"authorization": f"Bearer {access_token}"},
)
if not r.ok:
raise ValueError(f"Failed to get profile: {r.text}")
return r.json()
def _get_series(self, content_id: str) -> list[Episode]:
payload = {
"operationName": "GetShowpage",
"variables": {
"ids": [f"{content_id}"],
"sessionContext": {"userMaturity": "ADULT", "userLanguage": "EN"}
},
"query": """
query GetShowpage($sessionContext: SessionContext!, $ids: [String!]!) {
medias(sessionContext: $sessionContext, ids: $ids) {
id
title
mediaType
productionYear
originalLanguage {
id
displayName
}
seasons {
id
title
seasonNumber
}
}
}
"""
}
res = self.session.post(
url=self.config["endpoints"]["graphql"],
headers={"authorization": f"Bearer {self.graphql_token}"},
json=payload
)
if not res.ok:
raise ConnectionError(f"Failed to request the title for {content_id}: {res.text}")
data = res.json()
if not data["data"]["medias"]:
raise ValueError(f"Failed to find the title for {content_id}")
original_language = data["data"]["medias"][0].get("originalLanguage", {}).get("id", "en")
seasons = data["data"]["medias"][0]["seasons"]
episodes = self._fetch_episodes(seasons)
return [
Episode(
id_=episode.get("id"),
service=self.__class__,
title=episode.get("media", {}).get("title"),
year=episode.get("media", {}).get("productionYear"),
season=int(episode.get("seasonNumber", 0)),
number=int(episode.get("episodeNumber", 0)),
name=episode.get("title"),
language=original_language,
data=episode,
)
for episode in episodes
]
def _get_movie(self, content_id: str) -> list[Movie]:
payload = {
"operationName": "GetShowpage",
"variables": {
"ids": [f"{content_id}"],
"sessionContext": {"userMaturity": "ADULT", "userLanguage": "EN"}
},
"query": """
query GetShowpage($sessionContext: SessionContext!, $ids: [String!]!) {
medias(sessionContext: $sessionContext, ids: $ids) {
id
title
mediaType
productionYear
originalLanguage {
id
displayName
}
firstContent {
id
}
}
}
"""
}
res = self.session.post(
url=self.config["endpoints"]["graphql"],
headers={"authorization": f"Bearer {self.graphql_token}"},
json=payload
)
if not res.ok:
raise ConnectionError(f"Failed to request the title for {content_id}: {res.text}")
data = res.json()
if not data["data"]["medias"]:
raise ValueError(f"Failed to find the title for {content_id}")
original_language = data["data"]["medias"][0].get("originalLanguage", {}).get("id", "en")
title = data["data"]["medias"][0].get("title")
return [
Movie(
id_=data["data"]["medias"][0].get("firstContent", {}).get("id"),
service=self.__class__,
name=re.sub(r"\s\(\d{4}\)", "", title),
year=data["data"]["medias"][0].get("productionYear"),
language=original_language,
data=data["data"]["medias"][0],
)
]
def _get_episode(self, content_id: str) -> json:
payload = {
"operationName": "GetPlayerPage",
"variables": {"ids": [f"{content_id}"], "sessionContext": {"userMaturity": "ADULT", "userLanguage": "EN"}},
"query": """
query GetPlayerPage($sessionContext: SessionContext!, $ids: [String!]!) {
contents(sessionContext: $sessionContext, ids: $ids) {
id
path
title
shortDescription
seasonNumber
episodeNumber
contentType
broadcastDate
locked
media {
id
title
path
mediaType
productionYear
originalLanguage {
id
displayName
}
}
}
}
""",
}
res = self.session.post(
url=self.config["endpoints"]["graphql"],
headers={"authorization": f"Bearer {self.graphql_token}"},
json=payload
)
if not res.ok:
raise ConnectionError(f"Failed to request the title for {content_id}: {res.text}")
data = res.json()
if not (contents := data["data"]["contents"]):
raise ValueError(f"Failed to find content for {content_id}")
original_language = contents[0].get("media", {}).get("originalLanguage", {}).get("id", "en")
return [
Episode(
id_=content.get("id"),
service=self.__class__,
title=content.get("media", {}).get("title"),
year=content.get("media", {}).get("productionYear"),
season=int(content.get("seasonNumber", 0)),
number=int(content.get("episodeNumber", 0)),
name=content.get("title"),
language=original_language,
data=content,
)
for content in contents
]
def _fetch_episode(self, season_id: str) -> json:
payload = {
"operationName": "GetContentBySeasonId",
"variables": {
"id": season_id,
"sessionContext": {"userMaturity": "ADULT", "userLanguage": "EN"},
"contentFormat": {"format": "LONGFORM"},
},
"query": """
query GetContentBySeasonId($sessionContext: SessionContext!, $id: String!, $contentFormat: ContentFormatRequest) {
contentsBySeasonId(
sessionContext: $sessionContext
id: $id
contentFormat: $contentFormat
) {
id
locked
title
episodeNumber
seasonNumber
broadcastDate
path
shortDescription
languageIndicators {
indicator
languages {
langCode
displayTitle
locked
}
}
media {
id
title
path
productionYear
}
}
}
"""
}
response = self.session.post(
url=self.config["endpoints"]["graphql"],
headers={"authorization": f"Bearer {self.graphql_token}"},
json=payload
)
if not response.ok:
raise ConnectionError(f"Failed to request the title for {season_id}: {response.text}")
data = response.json()
return data["data"]["contentsBySeasonId"]
def _fetch_episodes(self, data: dict) -> list:
with ThreadPoolExecutor(max_workers=10) as executor:
tasks = [executor.submit(self._fetch_episode, x["id"]) for x in data]
titles = [future.result() for future in as_completed(tasks)]
return [episode for episodes in titles for episode in episodes]
@@ -0,0 +1,16 @@
headers:
user-agent: "Dalvik/2.1.0 (Linux; U; Android 11; SHIELD Android TV Build/RQ1A.210105.003)"
endpoints:
graphql: "https://rte-api.bellmedia.ca/graphql"
login: "https://account.bellmedia.ca/api/login/v2.2"
account: "https://account.bellmedia.ca/api/account/v1.1"
profile: "https://account.bellmedia.ca/api/profile/v2/account/{}"
contents: "https://playback.rte-api.bellmedia.ca/contents/{}"
destination: "https://stream.video.9c9media.com/meta/content/{}/contentpackage/{}/destination/{}/platform/1"
widevine: "https://license.9c9media.com/widevine"
playready: "https://license.9c9media.com/playready"
auth:
android: "Y3JhdmUtYW5kcm9pZDpkZWZhdWx0"
web: "Y3JhdmUtd2ViOmRlZmF1bHQ="
@@ -1,407 +1,396 @@
import base64
import json
from __future__ import annotations
import hashlib
import re
from datetime import datetime, timezone
from http.cookiejar import CookieJar
from typing import List, Optional
from collections.abc import Generator
from http.cookiejar import MozillaCookieJar
from typing import Any, Optional
import click
import jwt
from click import Context
from langcodes import Language
from envied.core.constants import AnyTrack
from envied.core.credential import Credential
from envied.core.manifests import DASH
from envied.core.manifests import DASH, HLS
from envied.core.search_result import SearchResult
from envied.core.service import Service
from envied.core.titles import Episode, Movie, Movies, Series, Title_T, Titles_T
from envied.core.tracks import Subtitle, Tracks
from envied.core.titles import Episode, Movie, Movies, Series
from envied.core.tracks import Chapters, Subtitle, Tracks
class KNPY(Service):
"""
Service code for Kanopy (kanopy.com).
Version: 1.0.0
\b
Service code for Kanopy streaming service (https://www.kanopy.com/).
Auth: Credential (username + password)
Security: FHD@L3
\b
Version: 1.0.1
Author: stabbedbybrick
Authorization: Credentials
Geofence: None
Robustness:
widevine:
L3: 1080p
playready:
SL2000: 1080p
\b
Tips:
- Input can be complete title URL or video path:
MOVIE: https://www.kanopy.com/en/lapl/video/16239510 OR /video/16239510 OR /product/16239510
SERIES: https://www.kanopy.com/en/lapl/video/14949910 OR /video/14949910 OR /product/14949910
EPISODE: https://www.kanopy.com/en/lapl/video/14949910/14949912 OR /video/14949910/14949912
\b
Notes:
- Available tickets are checked on each run, but they don't appear to be used
when titles are accessed through the API.
Handles both Movies and Series (Playlists).
Detects and stops for movies that require tickets.
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
# GEOFENCE = ()
ALIASES = ("kanopy",)
TITLE_RE = r"^(?:.*?/)?(?P<type>watch/video|video|watch|product)/(?P<id1>\d+)(?:/(?P<id2>\d+))?/?$"
@staticmethod
@click.command(name="KNPY", short_help="https://kanopy.com")
@click.command(name="KNPY", short_help="https://www.kanopy.com/", help=__doc__)
@click.argument("title", type=str)
@click.pass_context
def cli(ctx, **kwargs):
def cli(ctx: Context, **kwargs: Any) -> KNPY:
return KNPY(ctx, **kwargs)
def __init__(self, ctx, title: str):
def __init__(self, ctx: Context, title: str):
self.title = title
super().__init__(ctx)
if not self.config:
raise ValueError("KNPY configuration not found. Ensure config.yaml exists.")
self.cdm = ctx.obj.cdm
self.session.headers.update(self.config["headers"])
match = re.match(self.TITLE_RE, title)
if match:
self.content_id = match.group("id")
else:
self.content_id = None
self.search_query = title
def authenticate(self, cookies: Optional[MozillaCookieJar] = None, credential: Optional[Credential] = None) -> None:
super().authenticate(cookies, credential)
if not credential:
raise EnvironmentError("Service requires Credentials for Authentication.")
self.API_VERSION = self.config["client"]["api_version"]
self.USER_AGENT = self.config["client"]["user_agent"]
self.WIDEVINE_UA = self.config["client"]["widevine_ua"]
self.session.headers.update({
"x-version": self.API_VERSION,
"user-agent": self.USER_AGENT
})
self._jwt = None
self._visitor_id = None
self._user_id = None
self._domain_id = None
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")
cache = self.cache.get(f"tokens_{credential.sha1}")
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.")
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)
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):
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()
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
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.info(" + Using cached tokens")
tokens = cache.data
else:
raise ValueError("No active library memberships found for this user.")
self.log.info(" + Logging in...")
payload = {
"credentialType": "email",
"emailUser": {
"email": credential.username,
"password": credential.password,
},
}
def get_titles(self) -> Titles_T:
if not self.content_id:
raise ValueError("A content ID is required to get titles. Use a URL or run a search first.")
if not self._domain_id:
raise ValueError("Domain ID not set. Authentication may have failed.")
r = self.session.get(self.config["endpoints"]["video_info"].format(video_id=self.content_id, domain_id=self._domain_id))
r.raise_for_status()
content_data = r.json()
content_type = content_data.get("type")
def parse_lang(data):
try:
langs = data.get("languages", [])
if langs and isinstance(langs, list) and len(langs) > 0:
return Language.find(langs[0])
except:
pass
return Language.get("en")
if content_type == "video":
video_data = content_data["video"]
movie = Movie(
id_=str(video_data["videoId"]),
service=self.__class__,
name=video_data["title"],
year=video_data.get("productionYear"),
description=video_data.get("descriptionHtml", ""),
language=parse_lang(video_data),
data=video_data,
response = self.session.post(
self.config["endpoints"]["login"], json=payload
)
return Movies([movie])
response.raise_for_status()
data = response.json()
elif content_type == "playlist":
playlist_data = content_data["playlist"]
series_title = playlist_data["title"]
series_year = playlist_data.get("productionYear")
if not (access_token := data.get("jwt")):
raise ConnectionError(f"Failed to login: {data}")
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
user_id = data.get("userId")
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()
expiry = jwt.decode(access_token, options={"verify_signature": False}).get("exp")
if not expiry:
expiry = 86400 # 24 hour fallback
episodes = []
for i, item in enumerate(items_data.get("list", [])):
if item.get("type") != "video":
tokens = {
"accessToken": access_token,
"userId": user_id,
}
cache.set(tokens, expiration=expiry - 3600)
user = self._get_membership(tokens.get("accessToken"), tokens.get("userId"))
self.subdomain = user.get("subdomain", "lapl")
self.site = user.get("sitename")
self.domain_id = user.get("domainId")
self.available_tickets = user.get("ticketsAvailable", 0)
self.log.info(f"Site: {self.site} | Available tickets: {self.available_tickets}")
self.user_id = tokens.get("userId")
self.access_token = tokens.get("accessToken")
self.session.headers.update({"authorization": "Bearer {}".format(self.access_token)})
def search(self) -> Generator[SearchResult, None, None]:
params = {
"query": self.title,
"sort": "relevance",
"rfp": "exclude",
"domainId": self.domain_id,
"isKids": "false",
"page": "0",
"perPage": "40",
}
response = self.session.get(self.config["endpoints"]["search"], params=params)
response.raise_for_status()
data = response.json()
for result in data.get("list", []):
yield SearchResult(
id_=f"/video/{result.get("videoId")}",
title=result.get("title"),
description=result.get("tagline"),
label="Video",
url=f"/video/{result.get("videoId")}",
)
def get_titles(self) -> Movies | Series:
match = re.match(self.TITLE_RE, self.title)
if not match:
raise ValueError(f"Invalid title: {self.title}")
_, content_id, video_id = (match.group(i) for i in ("type", "id1", "id2"))
response = self.session.get(
self.config["endpoints"]["videos"].format(video_id=content_id),
params={"domainId": self.domain_id, "ageRatingDomainId": self.domain_id},
)
response.raise_for_status()
data = response.json()
entity_type = data.get("type")
if entity_type in ("video"):
movie = self._get_movie(data)
return Movies(movie)
# listed as a single season
elif entity_type in ("playlist"):
episodes = self._get_playlist(data)
if video_id is not None:
episodes = [episode for episode in episodes if int(episode.id) == int(video_id)]
return Series(episodes)
# listed as a collection of seasons
elif entity_type in ("collection"):
episodes = self._get_collection(data)
if video_id is not None:
episodes = [episode for episode in episodes if int(episode.id) == int(video_id)]
return Series(episodes)
else:
raise ValueError(f"Unsupported content type: {entity_type}")
def get_tracks(self, title: Movie | Episode) -> Tracks:
json_data = {
"videoId": title.id,
"userId": self.user_id,
"domainId": self.domain_id,
}
response = self.session.post(
self.config["endpoints"]["plays"],
headers={
"authorization": "Bearer {}".format(self.access_token),
**self.config["headers"],
},
json=json_data,
)
response.raise_for_status()
data = response.json()
manifests = data.get("manifests", [])
stream_data = next((m for m in manifests if m.get("manifestType") == "dash"), None)
stream_format = DASH
# Fallback to HLS
if not stream_data:
stream_data = next((m for m in manifests if m.get("manifestType") == "hls"), None)
stream_format = HLS
if not stream_data:
raise ValueError(f"Failed to find manifest for title: {title}")
manifest = stream_data.get("url")
title.data["drm_id"] = stream_data.get("drmLicenseID")
self.session.headers.clear()
tracks = stream_format.from_url(manifest, self.session).to_tracks(title.language)
for caption in data.get("captions", []):
language = caption.get("language")
for sub in caption.get("files", []):
if sub.get("type") == "transcript":
continue
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)
if ep_match:
ep_num = int(ep_match.group(1))
episodes.append(
Episode(
id_=str(video_data["videoId"]),
service=self.__class__,
title=series_title,
season=season_num,
number=ep_num,
name=video_data["title"],
description=video_data.get("descriptionHtml", ""),
year=video_data.get("productionYear", series_year),
language=parse_lang(video_data),
data=video_data,
tracks.add(
Subtitle(
id_=hashlib.md5(sub.get("url").encode()).hexdigest()[0:6],
codec=Subtitle.Codec.from_codecs(sub.get("url").split(".")[-1]),
language=language,
url=sub.get("url"),
)
)
series = Series(episodes)
series.name = series_title
series.description = playlist_data.get("descriptionHtml", "")
series.year = series_year
return series
else:
raise ValueError(f"Unsupported content type: {content_type}")
def get_tracks(self, title: Title_T) -> Tracks:
play_payload = {
"videoId": int(title.id),
"domainId": int(self._domain_id),
"userId": int(self._user_id),
"visitorId": self._visitor_id
}
self.session.headers.setdefault("authorization", f"Bearer {self._jwt}")
self.session.headers.setdefault("x-version", self.API_VERSION)
self.session.headers.setdefault("user-agent", self.USER_AGENT)
r = self.session.post(self.config["endpoints"]["plays"], json=play_payload)
response_json = None
try:
response_json = r.json()
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."
)
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
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")
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")
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)
else:
self.log.warning(f"Unknown 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.")
self.log.info(f"Fetching DASH 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",
})
tracks = DASH.from_text(r.text, url=manifest_url).to_tracks(language=title.language)
for caption_data in play_data.get("captions", []):
lang = caption_data.get("language", "en")
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"],
codec=Subtitle.Codec.WebVTT,
language=Language.get(lang)
))
break
return tracks
def get_chapters(self, title: Movie | Episode) -> Chapters:
return Chapters()
def get_widevine_license(self, *, challenge: bytes, title: Title_T, track: AnyTrack) -> bytes:
if not self.widevine_license_url:
raise ValueError("Widevine license URL was not set. Call get_tracks first.")
def get_widevine_service_certificate(self, *, challenge: bytes, title: Episode | Movie, track: Any) -> bytes | str | None:
return None
license_headers = {
"Content-Type": "application/octet-stream",
"User-Agent": self.WIDEVINE_UA,
"Authorization": f"Bearer {self._jwt}",
"X-Version": self.API_VERSION
def get_widevine_license(self, *, challenge: bytes, title: Episode | Movie, track: Any) -> bytes | str | None:
if not (drm_id := title.data.get("drm_id")):
return None
license_url = self.config["endpoints"]["widevine"].format(drm_id=drm_id)
headers = {
"authorization": "Bearer {}".format(self.access_token),
"content-type": "application/octet-stream",
**self.config["headers"]
}
r = self.session.post(
self.widevine_license_url,
data=challenge,
headers=license_headers
)
r = self.session.post(url=license_url, headers=headers, data=challenge)
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 get_playready_license(self, *, challenge: bytes, title: Episode | Movie, track: Any) -> bytes | str | None:
if not (drm_id := title.data.get("drm_id")):
return None
# 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()
license_url = self.config["endpoints"]["playready"].format(drm_id=drm_id)
# results = []
# for item in search_data.get("list", []):
# item_type = item.get("type")
# if item_type not in ["playlist", "video"]:
# continue
headers = {
"authorization": "Bearer {}".format(self.access_token),
"content-type": "text/xml; charset=utf-8",
"SOAPAction": "http://schemas.microsoft.com/DRM/2007/03/protocols/AcquireLicense",
**self.config["headers"]
}
# video_id = item.get("videoId")
# title = item.get("title", "No Title")
# label = "Series" if item_type == "playlist" else "Movie"
r = self.session.post(url=license_url, headers=headers, data=challenge)
r.raise_for_status()
return r.content
# Service-specific methods
def _get_playlist(self, data: dict) -> list[Episode]:
if not (metadata := data.get("playlist")):
self.log.error(" - Error: No metadata found for this title.")
return []
series_title = metadata.get("title")
season = re.search(r"\b(?:S|Season|Series)\s*(\d+)", series_title, re.IGNORECASE)
season_number = int(season.group(1)) if season else 1
response = self.session.get(
self.config["endpoints"]["items"].format(video_id=metadata.get("videoId")),
params={"domainId": self.domain_id, "ageRatingDomainId": self.domain_id}
)
if response.status_code != 200:
self.log.error(f" - Error: Failed to fetch playlist for ID: {metadata.get('videoId')} - {response.status_code}")
return []
data = response.json()
videos = []
for e, item in enumerate(data.get("list", [])):
if item.get("type", "").lower() != "video":
continue
episode = item.get("video", {})
if episode:
episode["number"] = e + 1
videos.append(episode)
episodes = []
for video in videos:
lang = next((x.get("name") for x in video.get("taxonomies", {}).get("languages", [])), "English")
episodes.append(
Episode(
id_=video.get("videoId"),
service=self.__class__,
title=series_title,
season=season_number,
number=video.get("number", 0),
name=video.get("title"),
year=video.get("productionYear"),
language=Language.find(lang).to_alpha3(),
data=video,
)
)
return episodes
def _get_collection(self, data: dict) -> list[Episode]:
if not (metadata := data.get("collection")):
self.log.error(" - Error: No metadata found for this title.")
return []
series_title = metadata.get("title")
response = self.session.get(
self.config["endpoints"]["items"].format(video_id=metadata.get("videoId")),
params={"domainId": self.domain_id, "ageRatingDomainId": self.domain_id}
)
response.raise_for_status()
data = response.json()
playlists = [i for i in data.get("list", []) if i.get("type", "").lower() == "playlist"]
if not playlists:
self.log.error(" - Error: No playlists found for this title.")
return []
episodes = []
for s, playlist in enumerate(playlists):
playlist_episodes = self._get_playlist(playlist)
for video in playlist_episodes:
video.title = series_title
video.season = video.season or s + 1
episodes.extend(playlist_episodes)
return episodes
def _get_movie(self, data: dict) -> list[Movie]:
if not (metadata := data.get("video")):
self.log.error(" - Error: No metadata found for this title.")
return []
lang = next((x.get("name") for x in metadata.get("languages", [])), "English")
return [
Movie(
id_=metadata.get("videoId"),
service=self.__class__,
name=metadata.get("title"),
year=metadata.get("productionYear"),
language=Language.find(lang).to_alpha3(),
data=metadata,
)
]
def _get_membership(self, access_token: str, user_id: str) -> dict:
response = self.session.get(
self.config["endpoints"]["memberships"],
headers={"authorization": "Bearer {}".format(access_token)},
params={"userId": user_id},
)
response.raise_for_status()
data = response.json()
user_info = next((i for i in data.get("list", []) if i.get("userId") == user_id), None)
if not user_info:
raise ValueError(f"Failed to get membership info for user: {data}")
if user_info.get("status", "").lower() != "active":
raise ValueError(f"User is not active: {user_info}")
return user_info
# results.append(
# SearchResult(
# id_=str(video_id),
# title=title,
# description="",
# label=label,
# url=f"https://www.kanopy.com/watch/{video_id}"
# )
# )
# return results
def get_chapters(self, title: Title_T) -> list:
return []
@@ -1,15 +1,15 @@
client:
api_version: "Android/com.kanopy/6.21.0/952 (SM-A525F; Android 15)"
user_agent: "okhttp/5.2.1"
widevine_ua: "KanopyApplication/6.21.0 (Linux;Android 15) AndroidXMedia3/1.8.0"
headers:
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"
x-version: "web/undefined/undefined/undefined"
endpoints:
handshake: "https://kanopy.com/kapi/handshake"
login: "https://kanopy.com/kapi/login"
memberships: "https://kanopy.com/kapi/memberships?userId={user_id}"
video_info: "https://kanopy.com/kapi/videos/{video_id}?domainId={domain_id}"
video_items: "https://kanopy.com/kapi/videos/{video_id}/items?domainId={domain_id}"
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}"
login: "https://www.kanopy.com/kapi/login"
users: "https://www.kanopy.com/kapi/users/{user_id}"
memberships: "https://www.kanopy.com/kapi/memberships"
institutions: "https://www.kanopy.com/kapi/institutions/alias/{alias}"
search: "https://www.kanopy.com/kapi/search/videos"
videos: "https://www.kanopy.com/kapi/videos/{video_id}"
items: "https://www.kanopy.com/kapi/videos/{video_id}/items"
plays: "https://www.kanopy.com/kapi/plays"
widevine: "https://www.kanopy.com/kapi/licenses/widevine/{drm_id}"
playready: "https://www.kanopy.com/kapi/licenses/playready/{drm_id}"