566 lines
20 KiB
Python
566 lines
20 KiB
Python
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
|