This commit is contained in:
VineFeeder
2026-04-26 13:27:46 +01:00
parent 070f809da3
commit 9e3d6dea08
8 changed files with 776 additions and 309 deletions
+1 -1
View File
@@ -36,7 +36,7 @@ dependencies = [
"fonttools>=4.60.2,<5",
"jsonpickle>=3.0.4,<5",
"langcodes>=3.4.0,<4",
"lxml>=5.2.1,<7",
"lxml>=6.1.0,<7",
"pproxy>=2.7.9,<3",
"protobuf>=4.25.3,<7",
"pycaption>=2.2.6,<3",
@@ -1,23 +1,29 @@
from __future__ import annotations
import base64
import concurrent.futures
import json
import re
import time
from collections.abc import Generator
from datetime import timedelta
from http.cookiejar import MozillaCookieJar
from typing import Any, Optional, Union
from urllib.parse import urljoin, urlparse
from typing import Any, Optional
from urllib.parse import urlparse
import click
import jwt
from click import Context
from langcodes import Language
from lxml import etree
from pywidevine.cdm import Cdm as WidevineCdm
from requests import Request
from envied.core import __version__
from envied.core.cdm.detect import is_playready_cdm
from envied.core.config import config
from envied.core.credential import Credential
from envied.core.manifests.dash import DASH
from envied.core.search_result import SearchResult
from envied.core.service import Service
from envied.core.titles import Episode, Movie, Movies, Series
from envied.core.tracks import Chapters, Tracks
from envied.core.tracks import Chapter, Chapters, Tracks
class TVNZ(Service):
@@ -26,283 +32,701 @@ class TVNZ(Service):
Service code for TVNZ streaming service (https://www.tvnz.co.nz).
\b
Version: 1.0.2
Version: 2.0.2
Author: stabbedbybrick
Authorization: Credentials
Authorization: tokens
Robustness:
L3: 1080p, AAC2.0
Widevine:
L3: 1080p, DDP5.1
PlayReady:
SL2000: 1080p, DDP5.1
\b
Tips:
- Input can be comlete URL or path:
SHOW: /shows/tulsa-king
EPISODE: /shows/tulsa-king/episodes/s1-e1
MOVIE: /shows/the-revenant
SPORT: /sport/tennis/wta-tour/guadalajara-open-final
SHOW: /tvseries/the-rookie
EPISODE: /player/tvepisode/the-rookie-1
MOVIE: /movie/stand-by-me
EVENT: /player/event/bula-fc-v-auckland-fc
HIGHLIGHT: /player/sporthighlight/sheep-dog-trials
CLIP: /player/newsclip/drone-captures-slip-that-caused-evacuations-blocked-auckland-road
\b
Notes:
TVNZ has moved to an OTP-only login system, with no username/password and no cookies.
Auth sessions are stored in the browser's local storage, so they need to be extracted once
before being cached for future use.
There are many ways to extract it, but the easiest is with a browser extension, such as
"Cookie & Storage Exporter" or similar. Name the exported JSON file 'local_storage.json' and place it
in the `TwinVine/Cache/TVNZ` directory and it'll be added to cache on the next run.
Do note that the session can't be shared between browser and script, and will invalidate the other session
when tokens are refreshed. It's recommended to use a separate account for ripping purposes.
"""
GEOFENCE = ("nz",)
TITLE_RE = r"^(?:https?://(?:www\.)?tvnz\.co\.nz)?/(?:player/)?(tvseries|tvepisode|movie|event|sporthighlight|newsclip|sportclip)/([^/]+)$"
@staticmethod
@click.command(name="TVNZ", short_help="https://www.tvnz.co.nz", help=__doc__)
@click.command(name="TVNZ", short_help="https://tvnz.co.nz", help=__doc__)
@click.argument("title", type=str)
@click.pass_context
def cli(ctx: Context, **kwargs: Any) -> TVNZ:
return TVNZ(ctx, **kwargs)
def __init__(self, ctx: Context, title: str):
self.title = title
super().__init__(ctx)
self.title = title
self.drm_system = "playready" if is_playready_cdm(ctx.obj.cdm) else "widevine"
self.profile = ctx.parent.params.get("profile") or "default"
self.session.headers.update(self.config["headers"])
def search(self) -> Generator[SearchResult, None, None]:
params = {
"q": self.title.strip(),
"includeTypes": "all",
"mode": "detail",
"st": "published",
"term": self.title,
"pageNumber": "1",
"pageSize": "50",
"reg": "nz",
"dt": "web",
"client": "tvnz-tvnz-web",
"pf": "Regular",
"allowpg": "true",
}
results = self._request("GET", "/api/v1/android/play/search", params=params)["results"]
response = self.session.get(self.config["endpoints"]["search"], params=params).json()
if response.get("header", {}).get("message", "").lower() != "success":
self.log.error(f"Failed to get search results for '{self.title}'")
return
results = response.get("data", [])
for result in results:
content_type = result.get("cty")
content_id = result.get("nu")
title = result.get("lon", [{"n": ""}])[0].get("n")
synopsis = result.get("losd", [{"n": ""}])[0].get("n")
yield SearchResult(
id_=result["page"].get("url"),
title=result.get("title"),
description=result.get("synopsis"),
label=result.get("type"),
url="https://www.tvnz.co.nz" + result["page"].get("url"),
id_=f"https://tvnz.co.nz/{content_type}/{content_id}",
title=title,
description=synopsis,
label=content_type,
url=f"https://tvnz.co.nz/{content_type}/{content_id}",
)
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.")
cache = self.cache.get(f"tokens_{credential.sha1}")
user_tokens, session_tokens = self._get_cached_tokens(self.profile)
if cache and not cache.expired:
self.log.info(" + Using cached Tokens...")
tokens = cache.data
# If cache is missing or invalid, fallback to local storage
if not user_tokens or not session_tokens:
user_tokens, session_tokens = self._fetch_and_cache_local_storage(self.profile)
self.access_token = user_tokens["access_token"]
self.device_ref = user_tokens["deviceref"]
self.contact_id = user_tokens["contact_id"]
self.xauthorization = session_tokens["xauthorization"]
self.oauth_token = self._get_oauth_token()
self.secret = self._register_app(self.oauth_token, self.xauthorization, self.device_ref)
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_type, _ = match.groups()
title_path = urlparse(self.title).path.replace("/player/", "/")
if content_type == "tvseries":
return self._get_series(title_path)
elif content_type == "tvepisode":
return self._get_episode(title_path)
elif content_type == "movie":
return self._get_movie(title_path)
elif content_type in ("event", "sporthighlight", "newsclip", "sportclip"):
return self._get_single(title_path)
else:
self.log.info(" + Logging in...")
payload = {"email": credential.username, "password": credential.password, "keepMeLoggedIn": True}
raise ValueError(f"Unsupported content type: {content_type}")
response = self.session.post(
self.config["endpoints"]["base_api"] + "/api/v1/androidtv/consumer/login", json=payload
)
response.raise_for_status()
if not response.headers.get("aat"):
raise ValueError("Failed to authenticate: " + response.text)
def get_tracks(self, title: Movie | Episode) -> Tracks:
device_token = self._get_device_token(self.secret, self.device_ref)
tokens = {
"access_token": response.headers.get("aat"),
"aft_token": response.headers.get("aft"), # ?
}
headers = {
"accept": "*/*",
"accept-language": "en-US,en;q=0.9",
"authorization": f"Bearer {self.oauth_token}",
"content-type": "application/json",
"origin": "https://tvnz.co.nz",
"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-authorization": f"{self.xauthorization}",
"x-client-id": "tvnz-tvnz-web",
"x-device-id": f"{device_token}",
"x-device-type": "web",
}
cache.set(tokens, expiration=response.headers.get("aat_expires_in"))
json_data = {
"deviceName": "web",
"deviceId": f"{self.device_ref}",
"contentId": title.id,
"contentTypeId": "vod",
"catalogType": title.data.get("cty"),
"mediaFormat": "dash",
"drm": self.drm_system,
"delivery": "streaming",
"disableSsai": "true",
"deviceManufacturer": "web",
"deviceModelName": "Chrome browser on Windows",
"deviceModelNumber": "Chrome",
"deviceOs": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36",
"supportedAudioCodecs": "mp4a",
"supportedVideoCodecs": "avc,hevc,av01",
"supportedMaxWVSecurityLevel": "L3",
"deviceToken": f"{device_token}",
"urlParameters": {
"vpa": "click",
"rdid": f"{self.device_ref}",
"is_lat": "0",
"npa": "0",
"idtype": "dpid",
"endpoint": "web",
"endpoint-group": "desktop",
"endpoint_detail": "desktop",
},
}
self.session.headers.update({"Authorization": "Bearer {}".format(tokens["access_token"])})
response = self.session.post(
url=self.config["endpoints"]["authorize"],
headers=headers,
json=json_data,
timeout=30,
)
response.raise_for_status()
data = response.json()
if data.get("header", {}).get("message", "").lower() != "success":
raise ConnectionError(f"Failed to authorize playback: {data}")
title.data["license_url"] = data.get("data", {}).get("licenseUrl")
title.data["markers"] = title.data.get("mar")
# Disable SSL verification due to issues with newer versions of requests library.
self.session.verify = False
source_manifest = data.get("data", {}).get("contentUrl").split("?")[0]
def get_titles(self) -> Union[Movies, Series]:
try:
path = urlparse(self.title).path
except Exception as e:
raise ValueError("Could not parse ID from title: {}".format(e))
page = self._request("GET", "/api/v4/androidtv/play/page/{}".format(path))
if page["layout"].get("video"):
title = page.get("title", "").replace("Episodes", "")
video = self._request("GET", page["layout"]["video"].get("href"))
episodes = self._episode(video, title)
return Series(episodes)
# Temporary workaround for non-development branch
if __version__ < "5.0.0":
manifest = self._modify_transfer(source_manifest)
tracks = DASH.from_text(url=source_manifest, text=manifest).to_tracks(language=title.language)
else:
module = page["layout"]["slots"]["main"]["modules"][0]
label = module.get("label", "")
lists = module.get("lists")
title = page.get("title", "").replace(label, "")
seasons = [x.get("href") for x in lists]
episodes = []
for season in seasons:
data = self._request("GET", season)
episodes.extend([x for x in data["_embedded"].values()])
while data.get("nextPage"):
data = self._request("GET", data["nextPage"])
episodes.extend([x for x in data["_embedded"].values()])
if label in ("Episodes", "Stream"):
episodes = self._show(episodes, title)
return Series(episodes)
elif label in ("Movie", "Movies"):
movie = self._movie(episodes, title)
return Movies(movie)
def get_tracks(self, title: Union[Movie, Episode]) -> Tracks:
metadata = title.data.get("publisherMetadata") or title.data.get("media")
if not metadata:
self.log.error("Unable to find metadata for this episode")
return
source = metadata.get("type") or metadata.get("source")
video_id = metadata.get("brightcoveVideoId") or metadata.get("id")
account_id = metadata.get("brightcoveAccountId") or metadata.get("accountId")
playback = title.data.get("playbackHref", "")
self.drm_token = None
if source != "brightcove":
data = self._request("GET", playback)
self.license = (
data["encryption"]["licenseServers"]["widevine"]
if data["encryption"].get("drmEnabled")
else None
)
self.drm_token = data["encryption"].get("drmToken")
source_manifest = data["streaming"]["dash"].get("url")
else:
data = self._request(
"GET", self.config["endpoints"]["brightcove"].format(account_id, video_id),
headers={"BCOV-POLICY": self.config["policy"]},
)
self.license = next((
x["key_systems"]["com.widevine.alpha"]["license_url"]
for x in data["sources"]
if x.get("key_systems").get("com.widevine.alpha")),
None,
)
source_manifest = next((
x["src"] for x in data["sources"]
if x.get("key_systems").get("com.widevine.alpha")),
None,
)
manifest = self.trim_duration(source_manifest)
tracks = DASH.from_text(manifest, source_manifest).to_tracks(title.language)
tracks = DASH.from_url(source_manifest, self.session).to_tracks(language=title.language)
for track in tracks.audio:
role = track.data["dash"]["representation"].find("Role")
role = track.data["dash"]["adaptation_set"].find("Role")
if role is not None and role.get("value") in ["description", "alternative", "alternate"]:
track.descriptive = True
return tracks
def get_chapters(self, title: Union[Movie, Episode]) -> Chapters:
return Chapters()
def get_chapters(self, title: Movie | Episode) -> Chapters:
if not (markers := title.data.get("markers")):
return Chapters()
chapters = []
for marker in markers:
if marker.get("t", "").lower() == "postplay":
chapters.append(Chapter(name="Credits", timestamp=marker.get("m_st") * 1000))
else:
chapters.append(Chapter(name=marker.get("t"), timestamp=marker.get("m_st") * 1000))
chapters.append(Chapter(timestamp=marker.get("m_ed")))
def get_widevine_service_certificate(self, **_: Any) -> str:
return WidevineCdm.common_privacy_cert
if not any(c.timestamp == "00:00:00.000" for c in chapters):
chapters.append(Chapter(timestamp=0))
def get_widevine_license(self, challenge: bytes, **_: Any) -> str:
if not self.license:
return sorted(chapters, key=lambda x: x.timestamp)
def get_widevine_service_certificate(self, *, challenge: bytes, title: Episode | Movie, track: Any) -> bytes | str | None:
return None
def get_widevine_license(self, *, challenge: bytes, title: Episode | Movie, track: Any) -> bytes | str | None:
if not (license_url := title.data.get("license_url")):
return None
headers = {"Authorization": f"Bearer {self.drm_token}"} if self.drm_token else self.session.headers
r = self.session.post(self.license, headers=headers, data=challenge)
headers = {
'accept': '*/*',
'authorization': 'Bearer {}'.format(self.oauth_token),
'origin': 'https://tvnz.co.nz',
'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',
}
r = self.session.post(url=license_url, headers=headers, data=challenge)
r.raise_for_status()
return r.content
def get_playready_license(self, *, challenge: bytes, title: Episode | Movie, track: Any) -> bytes | str | None:
if not (license_url := title.data.get("license_url")):
return None
headers = {
'accept': '*/*',
'authorization': 'Bearer {}'.format(self.oauth_token),
'origin': 'https://tvnz.co.nz',
'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',
}
r = self.session.post(url=license_url, headers=headers, data=challenge)
r.raise_for_status()
return r.content
# Service specific
# Service-specific methods
def _show(self, episodes: list, title: str) -> Episode:
return [
Episode(
id_=episode.get("videoId"),
service=self.__class__,
title=title,
season=int(episode.get("seasonNumber")) if episode.get("seasonNumber") else 0,
number=int(episode.get("episodeNumber")) if episode.get("episodeNumber") else 0,
name=episode.get("title"),
language="en",
data=episode,
def _fetch_season_episodes(self, series_id: str, season_id: str) -> list[Episode]:
try:
response = self.session.get(
url=self.config["endpoints"]["episodes"].format(series_id=series_id),
params={
"seasonId": season_id,
"pageNumber": "1",
"pageSize": "99",
"sortBy": "epnum",
"sortOrder": "asc",
"reg": "nz",
"dt": "web",
"client": "tvnz-tvnz-web",
"pf": "Regular",
"allowpg": "true",
},
timeout=30,
)
for episode in episodes
]
response.raise_for_status()
data = response.json()
if not (episodes := data.get("data")):
self.log.error(f"Failed to get episodes for season {season_id}")
return []
return [
Episode(
id_=episode.get("nu"),
service=self.__class__,
title=episode.get("lostl")[0].get("n"),
season=episode.get("snum"),
number=episode.get("epnum"),
name=episode.get("lodn")[0].get("n"),
# year=episode.get("oadt", "").split("-")[0],
language=Language.find(episode.get("aud_lg", ["English"])[0]).to_alpha3(),
data=episode,
)
for episode in episodes
]
except Exception as e:
self.log.error(f"Failed to fetch season {season_id}: {e}")
return []
def _movie(self, movies: list, title: str) -> Movie:
return [
def _get_series(self, title_path: str) -> Series:
response = self.session.get(
url=self.config["endpoints"]["catalog"] + title_path,
params={
"reg": "nz",
"dt": "web",
"client": "tvnz-tvnz-web",
"pf": "Regular",
"allowpg": "true",
},
timeout=30,
)
response.raise_for_status()
data = response.json()
if not (series_id := data.get("data", {}).get("id")):
raise ValueError(f"Failed to get series ID: {data}")
response = self.session.get(
url=self.config["endpoints"]["seasons"].format(series_id=series_id),
params={
"pageNumber": "1",
"pageSize": "99",
"sortBy": "asc",
"sortOrder": "desc",
"reg": "nz",
"dt": "web",
"client": "tvnz-tvnz-web",
"pf": "Regular",
"allowpg": "true",
},
timeout=30,
)
response.raise_for_status()
data = response.json()
seasons = [x.get("id") for x in data["data"]]
if not seasons:
raise ValueError(f"Failed to get seasons: {data}")
all_episodes = []
with concurrent.futures.ThreadPoolExecutor(max_workers=10) as executor:
futures = [
executor.submit(self._fetch_season_episodes, series_id, season)
for season in seasons
]
for future in futures:
all_episodes.extend(future.result())
return Series(all_episodes)
def _get_movie(self, title_path: str) -> Movies:
response = self.session.get(
url=self.config["endpoints"]["catalog"] + title_path,
params={
"reg": "nz",
"dt": "web",
"client": "tvnz-tvnz-web",
"pf": "Regular",
"allowpg": "true",
},
timeout=30,
)
response.raise_for_status()
data = response.json()
if not (video := data.get("data")):
raise ValueError(f"Failed to get movie: {data}")
movies = [
Movie(
id_=movie.get("videoId"),
id_=video.get("nu"),
service=self.__class__,
name=title,
name=video.get("lodn", [{"n": ""}])[0].get("n"),
year=None,
language="en",
data=movie,
)
for movie in movies
]
def _episode(self, video: dict, title: str) -> Episode:
kind = video.get("type")
name = video.get("title")
if kind == "sportVideo" and video.get("_embedded"):
_type = next((x for x in video["_embedded"].values() if x.get("type") == "competition"), None)
title = _type.get("title") if _type else title
name = video.get("title", "") + " " + video.get("phase", "")
return [
Episode(
id_=video.get("videoId"),
service=self.__class__,
title=title,
season=int(video.get("seasonNumber")) if video.get("seasonNumber") else 0,
number=int(video.get("episodeNumber")) if video.get("episodeNumber") else 0,
name=name,
language="en",
language=Language.find(video.get("aud_lg", ["English"])[0]).to_alpha3(),
data=video,
)
]
def _request(
self,
method: str,
api: str,
params: dict = None,
headers: dict = None,
payload: dict = None,
) -> Any[dict | str]:
url = urljoin(self.config["endpoints"]["base_api"], api)
if headers:
self.session.headers.update(headers)
return Movies(movies)
prep = self.session.prepare_request(Request(method, url, params=params, json=payload))
response = self.session.send(prep)
def _get_episode(self, title_path: str) -> Series:
response = self.session.get(
url=self.config["endpoints"]["catalog"] + title_path,
params={
"reg": "nz",
"dt": "web",
"client": "tvnz-tvnz-web",
"pf": "Regular",
"allowpg": "true",
},
timeout=30,
)
response.raise_for_status()
data = response.json()
if not (video := data.get("data")):
raise ValueError(f"Failed to get episode: {data}")
episodes = [
Episode(
id_=video.get("nu"),
service=self.__class__,
title=video.get("lostl")[0].get("n"),
season=video.get("snum"),
number=video.get("epnum"),
name=video.get("lodn", [{"n": ""}])[0].get("n"),
# year=video.get("oadt", "").split("-")[0],
language=Language.find(video.get("aud_lg", ["English"])[0]).to_alpha3(),
data=video,
)
]
return Series(episodes)
def _get_single(self, title_path: str) -> Movies:
response = self.session.get(
url=self.config["endpoints"]["catalog"] + title_path,
params={
"reg": "nz",
"dt": "web",
"client": "tvnz-tvnz-web",
"pf": "Regular",
"allowpg": "true",
},
timeout=30,
)
response.raise_for_status()
data = response.json()
if not (video := data.get("data")):
raise ValueError(f"Failed to get episode: {data}")
events = [
Movie(
id_=video.get("nu"),
service=self.__class__,
name=video.get("lodn", [{"n": ""}])[0].get("n"),
year=video.get("r"),
language=Language.find(video.get("aud_lg", ["English"])[0]).to_alpha3(),
data=video,
)
]
return Movies(events)
@staticmethod
def _get_device_token(secret_b64: str, device_id: str) -> str:
secret_bytes = base64.b64decode(secret_b64)
payload = {
"deviceId": device_id,
"aud": "playback-auth-service",
"iat": int(time.time()),
"exp": int(time.time()) + 30
}
device_token = jwt.encode(payload, secret_bytes, algorithm="HS256")
return device_token
def _get_entitlements(self, access_token: str, contact_id: str):
headers = {
"accept": "application/json, text/plain, */*",
"accept-language": "en-US,en;q=0.9",
"authorization": f"Bearer {access_token}",
"content-type": "application/json",
"origin": "https://tvnz.co.nz",
"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",
}
json_data = {
"GetEntitlementsRequestMessage": {
"contactID": contact_id,
**self.config["contact"],
"returnUpgradableFlag": "true",
"returnProductAttributes": "true",
},
}
response = self.session.post(
url=self.config["endpoints"]["entitlements"],
headers=headers,
json=json_data,
timeout=30,
)
response.raise_for_status()
data = response.json()
if data.get("GetEntitlementsResponseMessage").get("message", "").lower() != "success":
raise ConnectionError(f"Failed to get entitlements: {data}")
token = data.get("GetEntitlementsResponseMessage").get("ovatToken")
expiry = data.get("GetEntitlementsResponseMessage").get("ovatTokenExpiry")
if not token:
raise ValueError(f"Failed to get entitlements: {data}")
return token, expiry
def _get_contact_id(self, access_token: str):
headers = {
"accept": "application/json, text/plain, */*",
"accept-language": "en-US,en;q=0.9",
"authorization": f"Bearer {access_token}",
"content-type": "application/json",
"origin": "https://tvnz.co.nz",
"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",
}
json_data = {"GetContactRequestMessage": {**self.config["contact"]}}
response = self.session.post(
url=self.config["endpoints"]["contact"],
headers=headers,
json=json_data,
timeout=30,
)
response.raise_for_status()
data = response.json()
if data.get("GetContactResponseMessage", {}).get("message", "").lower() != "success":
raise ConnectionError(f"Failed to get contact: {data}")
return data["GetContactResponseMessage"]["contactMessage"][0]["contactID"]
def _get_oauth_token(self) -> str:
headers = {
"accept": "*/*",
"accept-language": "en-US,en;q=0.9",
"content-type": "application/x-www-form-urlencoded;charset=UTF-8",
"origin": "https://tvnz.co.nz",
"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",
}
data = {
**self.config["web_client"],
"grant_type": "client_credentials",
"audience": "edge-service",
"scope": "offline openid",
}
response = self.session.post(
url=self.config["endpoints"]["oauth"],
headers=headers,
data=data,
timeout=30,
)
response.raise_for_status()
data = response.json()
if not (token := data.get("access_token")):
raise ValueError("Failed to get OAuth token")
return token
def _register_app(self, oauth_token: str, xauth: str, deviceref: str) -> str:
headers = {
"accept": "*/*",
"accept-language": "en-US,en;q=0.9",
"authorization": f"Bearer {oauth_token}",
"content-type": "text/plain;charset=UTF-8",
"origin": "https://tvnz.co.nz",
"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-authorization": f"{xauth}",
"x-client-id": "tvnz-tvnz-web",
}
response = self.session.post(
url=self.config["endpoints"]["register"],
headers=headers,
data=json.dumps({"uniqueId": deviceref}),
timeout=30,
)
response.raise_for_status()
registration = response.json()
if not (secret := registration.get("data", {}).get("secret")):
raise ValueError(f"Failed to register app: {registration}")
return secret
def _refresh_user_tokens(self, tokens: dict) -> tuple[dict, int]:
headers = {
"accept": "application/json, text/plain, */*",
"accept-language": "en-US,en;q=0.9",
"content-type": "application/json",
"origin": "https://tvnz.co.nz",
"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",
}
json_data = {
"RefreshTokenRequestMessage": {
**self.config["contact"],
"refreshToken": tokens.get("refresh_token"),
},
}
response = self.session.post(
url=self.config["endpoints"]["refresh"],
headers=headers,
json=json_data,
timeout=30,
)
response.raise_for_status()
data = response.json()
if data.get("RefreshTokenResponseMessage", {}).get("message", "").lower() != "success":
raise ConnectionError(f"Failed to refresh user tokens: {data}")
access_token = data["RefreshTokenResponseMessage"]["accessToken"]
refresh_token = data["RefreshTokenResponseMessage"]["refreshToken"]
expiry = data["RefreshTokenResponseMessage"]["expiresIn"]
return {"access_token": access_token, "refresh_token": refresh_token}, expiry
def _refresh_session_tokens(self, tokens: dict) -> tuple[dict, int]:
xauthorization, xexpiry = self._get_entitlements(tokens.get("access_token"), tokens.get("contact_id"))
return {"x-authorization": xauthorization}, xexpiry
def _get_cached_tokens(self, profile: str) -> tuple[dict | None, dict | None]:
user_tokens = self.cache.get(f"{profile}_user_tokens")
session_tokens = self.cache.get(f"{profile}_session_tokens")
if not (user_tokens and session_tokens):
return None, None
if not user_tokens.expired:
self.log.info(" + Using cached user tokens")
ptokens = user_tokens.data
else:
self.log.info(" + Refreshing cached user tokens..")
ptokens, pexpiry = self._refresh_user_tokens(user_tokens.data)
ptokens["deviceref"] = user_tokens.data["deviceref"]
ptokens["contact_id"] = user_tokens.data["contact_id"]
user_tokens.set(ptokens, expiration=int(pexpiry) - 3600)
if not session_tokens.expired:
self.log.info(" + Using cached session tokens")
xtokens = session_tokens.data
else:
self.log.info(" + Refreshing cached session tokens..")
xtokens, xexpiry = self._refresh_session_tokens(session_tokens.data)
session_tokens.set(xtokens, expiration=int(xexpiry) - 3600)
return ptokens, xtokens
def _fetch_and_cache_local_storage(self, profile: str) -> tuple[dict, dict]:
self.log.info(" + Fetching tokens from local storage JSON..")
cache_dir = config.directories.cache / "TVNZ"
storage = next((
f for f in cache_dir.rglob("*.json")
if f.is_file() and any(t in f.name for t in ("localStorage", "local_storage"))
),None,)
if not storage:
raise EnvironmentError("'localStorage' not found. \nRun 'envied. dl TVNZ --help' for more information.")
try:
data = json.loads(response.content)
user = json.loads(storage.read_text())
except json.JSONDecodeError:
raise ValueError(f"'{storage}' is corrupted. \nRun 'envied. dl TVNZ --help' for more information.")
access_token = user.get("accessToken")
refresh_token = user.get("refreshToken")
device_ref = user.get("deviceref")
if data.get("message"):
raise ConnectionError(f"{response.status_code} - {data.get('message')}")
for token in (access_token, refresh_token, device_ref):
if not token:
raise ValueError(
f"Required token '{token}' is missing from '{storage}'. \nRun 'envied. dl TVNZ --help' for more information."
)
pexpiry = jwt.decode(access_token, options={"verify_signature": False}).get("exp")
contact_id = self._get_contact_id(access_token)
xauthorization, xexpiry = self._get_entitlements(access_token, contact_id)
return data
ptokens = {
"access_token": access_token,
"refresh_token": refresh_token,
"deviceref": device_ref,
"contact_id": contact_id,
}
xtokens = {
"xauthorization": xauthorization,
}
except Exception as e:
raise ConnectionError("Request failed: {} - {}".format(response.status_code, response.text))
user_tokens = self.cache.get(f"{profile}_user_tokens")
session_tokens = self.cache.get(f"{profile}_session_tokens")
def trim_duration(self, source_manifest: str) -> str:
user_tokens.set(ptokens, expiration=int(pexpiry) - 3600)
session_tokens.set(xtokens, expiration=int(xexpiry) - 3600)
return ptokens, xtokens
def _modify_transfer(self, source_manifest: str) -> str:
"""
The last segment on all tracks return a 404 for some reason, causing a failed download.
So we trim the duration by exactly one segment to account for that.
TODO: Calculate the segment duration instead of assuming length.
Change transfer type to "2" until dev branch is merged
"""
manifest = DASH.from_url(source_manifest, self.session).manifest
period_duration = manifest.get("mediaPresentationDuration")
period_duration = DASH.pt_to_sec(period_duration)
hours, minutes, seconds = str(timedelta(seconds=period_duration - 6)).split(":")
new_duration = f"PT{hours}H{minutes}M{seconds}S"
manifest.set("mediaPresentationDuration", new_duration)
periods = manifest.findall("Period")
for period in periods:
for adaptation_set in period.findall("AdaptationSet"):
for prop in adaptation_set.findall("SupplementalProperty"):
if (
prop is not None
and prop.get("schemeIdUri") == "urn:mpeg:mpegB:cicp:TransferCharacteristics"
):
prop.set("value", "2")
return etree.tostring(manifest, encoding="unicode")
@@ -1,9 +1,28 @@
headers:
User-Agent: "AndroidTV/!/!"
x-tvnz-api-client-id: "androidtv/!.!.!"
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"
endpoints:
base_api: "https://apis-public-prod.tech.tvnz.co.nz"
brightcove: "https://edge.api.brightcove.com/playback/v1/accounts/{}/videos/{}"
authorize: "https://watch-cdn.edge-api.tvnz.co.nz/media/content/authorize"
register: "https://watch-cdn.edge-api.tvnz.co.nz/device/app/register"
refresh: "https://rest-prod-tvnz.evergentpd.com/tvnz/refreshToken"
catalog: "https://data-store-cdn.cms-api.tvnz.co.nz/content/urn/resource/catalog"
seasons: "https://data-store-cdn.cms-api.tvnz.co.nz/content/series/{series_id}/seasons"
episodes: "https://data-store-cdn.cms-api.tvnz.co.nz/content/series/{series_id}/episodes"
entitlements: "https://rest-prod-tvnz.evergentpd.com/tvnz/getEntitlements"
contact: "https://rest-prod-tvnz.evergentpd.com/tvnz/getContact"
oauth: "https://watch-cdn.edge-api.tvnz.co.nz/oauth2/token"
search: "https://search-cdn.cms-api.tvnz.co.nz/content/search"
policy: "BCpkADawqM0IurzupiJKMb49WkxM__ngDMJ3GOQBhN2ri2Ci_lHwDWIpf4sLFc8bANMc-AVGfGR8GJNgxGqXsbjP1gHsK2Fpkoj6BSpwjrKBnv1D5l5iGPvVYCo"
web_client:
client_id: "webclient-ui-app"
client_secret: "f99d00b8-5b20-4c27-983d-d2895f3e9fec"
androidtv_client:
client_id: "android-ui-app"
client_secret: "dcb2b779-1cf8-4672-81ed-8ee1a8345389"
contact:
channelPartnerID: "TVNZ_NZ"
apiUser: "qpapiuser"
apiPassword: "Tv9z@pi2026$"
@@ -1,4 +1,5 @@
import json
from typing import List, Tuple
from scrapy import Selector
catppuccin_mocha = {
@@ -210,6 +211,31 @@ def extract_with_xpath(html, pattern, delete_pattern=None, index=0):
return None
def extract_aria_label_and_href(html: str) -> List[Tuple[str, str]]:
"""
Extract (aria-label, href) pairs from <a> tags in the supplied HTML.
Args:
html: Full HTML document as a string.
Returns:
A list of tuples in the form:
[
("The Boondock Saints (1999)", "/en-GB/movie/the-boondock-saints"),
...
]
"""
selector = Selector(text=html)
results = []
for a in selector.css("a[aria-label][href]"):
aria_label = a.attrib.get("aria-label", "").strip()
href = a.attrib.get("href", "").strip()
results.append((aria_label, href))
return results
def rinse(string):
"""Remove non-printable characters from a string."""
illegals = "*'%$!(),;"
@@ -274,16 +274,19 @@ class PlexLoader(BaseLoader):
None
"""
category = browse_url.split('/')[-1]
browse_url = f"https://luma.plex.tv/api/screen/on-demand/category/{category}"
browse_url = f"https://luma.plex.tv/api/v2/screen/on-demand/category/{category}"
beaupylist = [] # hold beaupy data for display and programme selection
headers = {
"Accept": "application/json",
"Origin": "https://plex.tv",
"X-Plex-Product": "Plex Mediaverse",
"X-Plex-Version": "1.0",
"X-Plex-Client-Identifier": str(uuid.uuid4()),
}
try:
myjson = self.parse_data(self.get_data(browse_url))
myjson = self.parse_data(self.get_data(browse_url, headers=headers))
res = jmespath.search(
"""
ui.list.content[].{
@@ -3,16 +3,16 @@ service_name: PLEX
options: -l en
media_dict:
action: https://watch.plex.tv/on-demand/category/action
animation: https://watch.plex.tv/on-demand/category/animation
comedy: https://watch.plex.tv/on-demand/category/comedy
crime: https://watch.plex.tv/on-demand/category/crime
documentary: https://watch.plex.tv/on-demand/category/documentary
drama: https://watch.plex.tv/on-demand/category/drama
en epsaniol: https://watch.plex.tv/on-demand/category/en-espanol
horror: https://watch.plex.tv/on-demand/category/horror
music: https://watch.plex.tv/on-demand/category/music
romance: https://watch.plex.tv/on-demand/category/romance
sci-fi: https://watch.plex.tv/on-demand/category/sci-fi
thriller: https://watch.plex.tv/on-demand/category/thriller
western: https://watch.plex.tv/on-demand/category/western
action: https://watch.plex.tv/en-GB/on-demand/category/action
animation: https://watch.plex.tv/en-GB/on-demand/category/animation
comedy: https://watch.plex.tv/en-GB/on-demand/category/comedy
crime: https://watch.plex.tv/en-GB/on-demand/category/crime
documentary: https://watch.plex.tv/en-GB/on-demand/category/documentary
drama: https://watch.plex.tv/en-GB/on-demand/category/drama
en epsaniol: https://watch.plex.tv/en-GB/on-demand/category/en-espanol
horror: https://watch.plex.tv/en-GB/on-demand/category/horror
music: https://watch.plex.tv/en-GB/on-demand/category/music
romance: https://watch.plex.tv/en-GB/on-demand/category/romance
sci-fi: https://watch.plex.tv/en-GB/on-demand/category/sci-fi
thriller: https://watch.plex.tv/en-GB/on-demand/category/thriller
western: https://watch.plex.tv/en-GB/on-demand/category/western