Unshackle-Services/TEN/__init__.py
2026-01-03 17:53:34 +02:00

566 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from __future__ import annotations
import base64
import concurrent.futures
import hashlib
import hmac
import json
import re
import sys
import time
import uuid
from collections.abc import Generator
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timezone
from http.cookiejar import MozillaCookieJar
from typing import Any, Optional, Union
import click
import m3u8
from click import Context
from langcodes import Language
from requests import Request
from unshackle.core.config import config
from unshackle.core.credential import Credential
from unshackle.core.downloaders import requests
from unshackle.core.manifests import HLS
from unshackle.core.search_result import SearchResult
from unshackle.core.service import Service
from unshackle.core.titles import Episode, Movie, Movies, Series
from unshackle.core.tracks import Chapter, Chapters, Subtitle, Tracks, Video
class TEN(Service):
"""
\b
Service code for 10Play streaming service (https://10.com.au/).
\b
Version: 1.0.2
Author: stabbedbybrick
Authorization: credentials
Geofence: AU (API and downloads)
Robustness:
AES: 1080p, AAC2.0
\b
Tips:
- Input should be complete URL:
SHOW: https://10.com.au/australian-survivor
EPISODE: https://10.com.au/australian-survivor/episodes/season-11-australia-v-the-world/episode-9/tpv250831fxatm
MOVIE: https://10.com.au/a-quiet-place
- Non-standard programmes (e.g. game shows/sports) have very inconsistent episode number labels. It's recommended to use episode URLs for those.
\b
Notes:
- 10Play uses transport streams for HLS, meaning the video and audio are a part of the same stream.
As a result, only videos are listed as tracks. But the audio will be included as well.
- Since 1080p streams require some manipulation of the manifest, n_m3u8dl_re downloader is required.
"""
GEOFENCE = ("au",)
ALIASES = (
"10play",
"tenplay",
)
@staticmethod
@click.command(name="TEN", short_help="https://10.com.au/", help=__doc__)
@click.argument("title", type=str)
@click.pass_context
def cli(ctx: Context, **kwargs: Any) -> TEN:
return TEN(ctx, **kwargs)
def __init__(self, ctx: Context, title: str):
self.title = title
super().__init__(ctx)
if config.downloader != "n_m3u8dl_re":
self.log.error(" - Error: n_m3u8dl_re downloader is required for this service.")
sys.exit(1)
def search(self) -> Generator[SearchResult, None, None]:
query = self.endpoints["searchApiEndpoint"] + self.title
results = self._request("GET", query)
for result in results:
clean_title = self._sanitize(result.get("title"))
yield SearchResult(
id_=f"https://10.com.au/{clean_title}",
title=result.get("title"),
description=result.get("abstractShowDescription"),
label=result.get("subtitle", "").split("|")[-1].strip(),
url=f"https://10.com.au/{clean_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.session.headers.update(self.config["headers"])
self.endpoints = self._request(
"GET", self.config["endpoints"]["config"], params={"SystemName": "tvos"}
)
cache = self.cache.get(f"tokens_{credential.sha1}")
if cache and not cache.expired:
self.log.info(" + Using cached Tokens...")
tokens = cache.data
elif cache and cache.expired:
self.log.info(" + Refreshing expired Tokens...")
payload = {
"alternativeToken": cache.data["alternativeToken"],
"refreshToken": cache.data["refreshToken"],
}
tokens = self._request(
"POST", self.endpoints["authConfig"]["refreshToken"], json=payload
)
cache.set(tokens, expiration=tokens["expiresIn"])
else:
self.log.info(" + Logging in...")
headers = {
"accept": "application/json, text/plain, */*",
"content-type": "application/json",
"user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36",
"origin": "https://10.com.au",
"referer": "https://10.com.au/",
}
login = self._request(
"POST",
self.config["endpoints"]["auth"],
headers=headers,
json={"email": credential.username, "password": credential.password},
)
access_token = login.get("jwt", {}).get("accessToken")
if not access_token:
raise ValueError(
"Failed to authenticate with credentials: " + login.text
)
identifier = str(uuid.uuid4())
payload = {
"deviceIdentifier": identifier,
"machine": "Hisense",
"system": "vidaa",
"systemVersion": "U6",
"platform": "vidaa",
"appVersion": "v1",
"ipAddress": "string",
}
device = self._request(
"POST", self.endpoints["authConfig"]["generateCode"], json=payload
)
code = device.get("code")
expiry = device.get("expiry")
if not code or not expiry:
raise ValueError("Failed to generate device code: " + device.text)
headers = {
"accept": "application/json, text/plain, */*",
"authorization": f"Bearer {access_token}",
"content-type": "application/json",
"user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36",
"origin": "https://10.com.au",
"referer": "https://10.com.au/activate",
}
activate = self._request(
"POST",
self.endpoints["activateApiEndpoint"],
headers=headers,
json={"code": code},
)
if not activate:
raise ValueError("Failed to activate device")
payload = {
"code": code,
"deviceIdentifier": identifier,
"expiry": expiry,
}
auth = self._request(
"POST",
self.endpoints["authConfig"]["validateCode"],
json={
"code": code,
"deviceIdentifier": identifier,
"expiry": expiry,
},
)
tokens = auth.get("jwt")
tokens["identifier"] = identifier
self.log.info(" + User successfully logged in, TV device activated")
cache.set(tokens, expiration=tokens.get("expiresIn"))
self.access_token = tokens.get("alternativeToken")
self.session.headers.update({"authorization": f"Bearer {self.access_token}"})
def get_titles(self) -> Union[Movies, Series]:
url_pattern = re.compile(
r"^https://10\.com\.au/(?:[a-z0-9-]+)"
r"(?:/episodes/(?:season-)?(?P<season>[a-z0-9-]+)/(?:episode-)?(?P<episode>[a-z0-9-]+)/(?P<id>[a-z0-9]+))?$"
)
match = url_pattern.match(self.title)
if not match:
raise ValueError(f"Could not parse ID from title: {self.title}")
matches = match.groupdict()
if not matches.get("id"):
show_id = self._get_html(self.title)
content = self._shows(show_id)
if "movie" in content.get("subtitle", "").lower():
movies = self._movie(content)
return Movies(movies)
else:
episodes = self._series(content)
return Series(episodes)
else:
episodes = self._episode(matches.get("id"))
return Series(episodes)
def get_tracks(self, title: Union[Movie, Episode]) -> Tracks:
playback_url = title.data.get("playbackApiEndpoint")
if not playback_url:
raise ValueError("Could not find playback URL for this title")
params = {
"device": "Tv",
"platform": "vidaa",
"appVersion": "v1",
}
r = self.session.get(playback_url, params=params)
if not r.ok:
raise ValueError("Failed to get playback data: " + r.text)
dai_auth = r.headers.get("X-DAI-AUTH")
video_id = r.headers.get("x-dai-video-id")
if dai_auth is not None:
payload = {"auth-token": dai_auth}
playback_data = r.json()
video_id = playback_data.get("dai", {}).get("videoId")
source_id = playback_data.get("dai", {}).get("contentSourceId", "2690006")
if not video_id or not source_id:
raise ValueError("Failed to get video ID: " + r.text)
dai_stream = f"https://dai.google.com/ondemand/v1/hls/content/{source_id}/vid/{video_id}/stream"
stream_data = self._request("POST", dai_stream, data=payload)
title.data["chapters"] = stream_data.get("time_events_url")
# program_language = Language.find(stream_data["customFields"].get("program_language", "en"))
manifest_url = stream_data.get("stream_manifest")
tracks = HLS.from_url(manifest_url, self.session).to_tracks(language="en")
tracks = self._add_tracks(tracks)
for track in tracks:
track.OnSegmentFilter = lambda x: re.search(r"redirector.googlevideo.com", x.uri)
track.downloader_args = {"--ad-keyword": "redirector.googlevideo.com"}
if isinstance(track, Subtitle):
track.downloader = requests
# if caption := stream_data.get("subtitles", [])[0].get("webvtt"):
# tracks.add(
# Subtitle(
# id_=hashlib.md5(caption.encode()).hexdigest()[0:6],
# url=caption,
# codec=Subtitle.Codec.from_mime(caption[-3:]),
# language=stream_data.get("subtitles", [])[0].get("language", "en"),
# )
# )
return tracks
def get_chapters(self, title: Union[Movie, Episode]) -> Chapters:
if not title.data.get("chapters"):
return Chapters()
events = self._request("GET", title.data["chapters"])
cue_points = events.get("cuepoints")
if not cue_points:
return Chapters()
chapters = []
for cue in cue_points:
chapters.append(Chapter(timestamp=float(cue["start_float"])))
chapters.append(Chapter(timestamp=float(cue["end_float"])))
return Chapters(chapters)
# Service specific
def _head_request(self, url: str) -> int:
try:
return self.session.head(url, timeout=10).status_code
except Exception:
return 0
def _check_and_add_track(
self, best_track: Video, quality_info: dict, source_bitrate: int
) -> Video | None:
playlist_uri = best_track.data["hls"]["playlist"].uri
playlist_text = self.session.get(playlist_uri).text
string_to_replace = f"-{source_bitrate}"
replacement_string = f"-{quality_info['bitrate']}"
lines = []
for line in playlist_text.splitlines():
if "redirector.googlevideo.com" in line:
continue
if string_to_replace in line:
line = line.replace(string_to_replace, replacement_string)
lines.append(line)
modified_playlist_text = "\n".join(lines)
playlist_obj = m3u8.loads(modified_playlist_text)
if not playlist_obj.segments:
return None
first_segment = playlist_obj.segments[0].uri
if self._head_request(first_segment) == 200:
playlist_file = config.directories.cache / "TEN" / f"playlist_{quality_info['quality']}.m3u8"
playlist_obj.dump(playlist_file)
video = Video(
id_=f"{best_track.id}-{quality_info['quality']}",
url=best_track.url,
height=quality_info["height"],
width=quality_info["width"],
bitrate=quality_info["bitrate"],
language=best_track.language,
codec=best_track.codec,
range_=best_track.range,
fps=best_track.fps,
descriptor=best_track.descriptor,
data=best_track.data.copy(),
from_file=playlist_file,
)
return video
return None
def _add_tracks(self, tracks: Tracks) -> Tracks:
if not tracks.videos:
return tracks
best_track = max(tracks.videos, key=lambda t: t.height or 0)
source_bitrate = {
1080: "5000000",
720: "3000000",
540: "1500000",
360: "750000",
}.get(best_track.height)
all_qualities = [
{"quality": "540p", "bitrate": 1500000, "height": 540, "width": 960},
{"quality": "720p", "bitrate": 3000000, "height": 720, "width": 1280},
{"quality": "1080p", "bitrate": 5000000, "height": 1080, "width": 1920},
]
qualities_to_check = [
q for q in all_qualities if q["height"] > best_track.height
]
if not qualities_to_check:
return tracks
with ThreadPoolExecutor(max_workers=len(qualities_to_check)) as executor:
future_to_track = {
executor.submit(self._check_and_add_track, best_track, quality, source_bitrate): quality
for quality in qualities_to_check
}
for future in concurrent.futures.as_completed(future_to_track):
new_track = future.result()
if new_track:
tracks.add(new_track)
return tracks
def _shows(self, show_id: str) -> dict:
show = self._request("GET", f'{self.endpoints["showsApiEndpoint"]}/{show_id}')
return show[0] if isinstance(show, list) else show
def _fetch_episode(self, url: str) -> list:
return self._request("GET", url)
def _series(self, content: dict) -> Episode:
season_list = content.get("seasons")
if not season_list:
raise ValueError("Could not find a season list for this title")
seasons = [
season.get("menuItems", [])[0].get("apiEndpoint")
for season in season_list
if season.get("menuItems", [])
and season.get("menuItems", [])[0].get("menuTitle", "").lower()
== "episodes"
]
if not seasons:
raise ValueError("Could not find a season list for this title")
with ThreadPoolExecutor(max_workers=10) as executor:
results = list(executor.map(self._fetch_episode, seasons))
titles = []
for result in results:
for episode in result:
ep_number = episode.get("episode")
sea_number = episode.get("season")
titles.append(
Episode(
id_=episode.get("id"),
service=self.__class__,
name=episode.get("vodTitle", "").split(" - ")[-1],
season=int(sea_number) if sea_number and sea_number.isdigit() else 0,
number=int(ep_number) if ep_number and ep_number.isdigit() else 0,
title=episode.get("tvShow"),
data=episode,
)
)
return titles
def _movie(self, data: dict) -> Movie:
endpoint = next(
(
season.get("menuItems", [])[0].get("apiEndpoint")
for season in data.get("seasons", [])
if season.get("menuItems", [])
),
None,
)
if not endpoint:
raise ValueError("Could not find an endpoint for this title")
movie = self._request("GET", endpoint)[0]
return [
Movie(
id_=movie.get("id"),
service=self.__class__,
name=movie.get("title"),
year=movie.get("season"),
data=movie,
)
]
def _episode(self, video_id: str) -> Episode:
data = self._request("GET", f"{self.endpoints['videosApiEndpoint']}/{video_id}")
ep_number = data.get("episode")
sea_number = data.get("season")
return [
Episode(
id_=data.get("id"),
service=self.__class__,
name=data.get("vodTitle", "").split(" - ")[-1],
season=int(sea_number) if sea_number and sea_number.isdigit() else 0,
number=int(ep_number) if ep_number and ep_number.isdigit() else 0,
title=data.get("tvShow"),
data=data,
)
]
def _get_html(self, url: str) -> Optional[str]:
page = self.session.get(url).text
pattern = re.compile(r"const showPageData = ({.*?});", re.DOTALL)
match = pattern.search(page)
if not match:
raise ValueError(
" - Failed to parse HTML. Page Data not found in the source code."
)
page_data = match.group(1)
try:
data = json.loads(page_data)
except json.JSONDecodeError as e:
raise json.JSONDecodeError(f"Failed to parse JSON: {e}")
show_id = data.get("video", {}).get("showUrlCode")
if not show_id:
raise ValueError(" - showUrlCode not found in the source code.")
return show_id
def _signature_header(self, url: str) -> str:
timestamp = int(time.time())
message = f"{timestamp}:{url}".encode("utf-8")
api_key = bytes.fromhex(self.config["api_key"])
signature = hmac.new(api_key, message, hashlib.sha256).hexdigest()
return f"{timestamp}_{signature}"
def _auth_header(self) -> str:
now_utc = datetime.now(timezone.utc)
timestamp_str = now_utc.strftime("%Y%m%d%H%M%S")
encoded_bytes = base64.b64encode(timestamp_str.encode("utf-8"))
return encoded_bytes.decode("ascii")
def _request(self, method: str, url: str, **kwargs: Any) -> Any[dict | str]:
if method == "GET":
self.session.headers.update(
{
"X-N10-SIG": self._signature_header(url),
"tp-acceptfeature": "v1/fw;v1/drm;v2/live",
"tp-platform": "UAP",
}
)
elif method == "POST":
self.session.headers.update({"X-Network-Ten-Auth": self._auth_header()})
prep = self.session.prepare_request(Request(method, url, **kwargs))
response = self.session.send(prep)
if response.status_code not in (200, 201):
raise ConnectionError(f"{response.text}")
try:
return json.loads(response.content)
except json.JSONDecodeError:
return True if "true" in response.text else False
@staticmethod
def _sanitize(title: str) -> str:
title = title.lower()
title = title.replace("&", "and")
title = re.sub(r"[:;/()]", "", title)
title = re.sub(r"[ ]", "-", title)
title = re.sub(r"[\\*!?¿,'\"<>|$#`]", "", title)
title = re.sub(rf"[{'.'}]{{2,}}", ".", title)
title = re.sub(rf"[{'_'}]{{2,}}", "_", title)
title = re.sub(rf"[{'-'}]{{2,}}", "-", title)
title = re.sub(rf"[{' '}]{{2,}}", " ", title)
return title