From 9536ab8e52c6b423a1de64bb72695054499a660b Mon Sep 17 00:00:00 2001 From: Mike Date: Fri, 29 Nov 2024 12:05:34 +0000 Subject: [PATCH] Upload files to "Services" --- Services/skyshowtime.py | 416 +++++++++++++++++++++++++++++++++++++++ Services/skyshowtime.yml | 26 +++ 2 files changed, 442 insertions(+) create mode 100644 Services/skyshowtime.py create mode 100644 Services/skyshowtime.yml diff --git a/Services/skyshowtime.py b/Services/skyshowtime.py new file mode 100644 index 0000000..6f4f854 --- /dev/null +++ b/Services/skyshowtime.py @@ -0,0 +1,416 @@ +from __future__ import annotations + +import base64 +import hashlib +import hmac +import json +import re +import time +import requests,json +import os +from datetime import datetime + + +import click +from click import Context + +from vinetrimmer.objects import MenuTrack, Title, Tracks +from vinetrimmer.services.BaseService import BaseService + + +class Skyshowtime(BaseService): + """ + Service code for NBC's Skyshowtime streaming service (https://skyshowtime.com). + Edited L4M + + \b + Authorization: Cookies + Security: UHD@-- FHD@L3, doesn't care about releases. + """ + + ALIASES = ["SKY", "Skyshowtime"] + #GEOFENCE = ["es"] + + VIDEO_RANGE_MAP = { + "DV": "DOLBY_VISION" + } + + AUDIO_CODEC_MAP = { + "AAC": "mp4a", + "AC3": "ac-3", + "EC3": "ec-3" + } + + @staticmethod + @click.command(name="Skyshowtime", short_help="https://skyshowtime.com") + @click.argument("title", type=str) + @click.option("-m", "--movie", is_flag=True, default=False, help="Title is a Movie.") + @click.pass_context + def cli(ctx, **kwargs): + return Skyshowtime(ctx, **kwargs) + + def __init__(self, ctx, title: str, movie: bool): + self.title = title + self.movie = movie + super().__init__(ctx) + + self.profile = ctx.obj.profile + + self.range = ctx.parent.params["range_"] + self.vcodec = ctx.parent.params["vcodec"] + self.acodec = ctx.parent.params["acodec"] + + if (ctx.parent.params.get("quality") or 0) > 1080 and self.vcodec != "H265": + self.log.info(" + Switched video codec to H265 to be able to get 2160p video track") + self.vcodec = "H265" + + if self.range in ("HDR10", "DV") and self.vcodec != "H265": + self.log.info(f" + Switched video codec to H265 to be able to get {self.range} dynamic range") + self.vcodec = "H265" + + + self.service_config = None + self.hmac_key: bytes + self.tokens: dict + self.license_api = None + self.license_bt = None + + self.configure() + + def get_titles(self): + # Title is a slug, example: `/tv/the-office/4902514835143843112`. + res = self.session.get( + url=self.config["endpoints"]["node"], + params={ + "slug": self.title,#'provider_series_id/'+self.title.split('/')[3], + "represent": "(items(items))", + # "represent": "(items(items),recs[take=8],collections(items(items[take=8])),trailers)" + }, + headers={ + "Accept": "*", + "Referer": f"https://www.skyshowtime.com/watch/asset{self.title}", + "x-skyott-Activeterritory": self.config["client"]["activeterritory"], + "x-skyott-device": self.config["client"]["device"], + "x-skyott-language": self.config["client"]["language"], + "x-skyott-platform": self.config["client"]["platform"], + "x-skyott-proposition": self.config["client"]["proposition"], + "x-skyott-provider": self.config["client"]["provider"], + "x-skyott-territory": self.config["client"]["territory"] + } + ) + if not res.ok: + self.log.exit(f" - HTTP Error {res.status_code}: {res.reason}") + raise + data = res.json() + + titles = [] + if "relationships" in data: + for season in data["relationships"]["items"]["data"]: + for episode in season["relationships"]["items"]["data"]: + titles.append(episode) + else: + return [Title( + id_=self.title, + type_=Title.Types.MOVIE, + name=data["attributes"]["title"], + year=data["attributes"].get("year"), + original_lang="en", # TODO: Don't assume + source=self.ALIASES[0], + service_data=data + )] + return [Title( + id_=self.title, + type_=Title.Types.TV, + name=data["attributes"]["title"], + year=x["attributes"].get("year"), + season=x["attributes"].get("seasonNumber"), + episode=x["attributes"].get("episodeNumber"), + episode_name=x["attributes"].get("title"), + original_lang="en", # TODO: Don't assume + source=self.ALIASES[0], + service_data=x + ) for x in titles] + + def get_tracks(self, title: Title) -> Tracks: + content_id = title.service_data["attributes"]["formats"]["HD"]["contentId"] + variant_id = title.service_data["attributes"]["providerVariantId"] + + sky_headers = { + # order of these matter! + "x-skyott-Activeterritory": self.config["client"]["activeterritory"], + "x-skyott-agent": ".".join([ + self.config["client"]["proposition"].lower(), + self.config["client"]["device"].lower(), + self.config["client"]["platform"].lower() + ]), + # "x-skyott-coppa": "false", + "x-skyott-device": self.config["client"]["device"], + "x-skyott-language": self.config["client"]["language"], + "x-skyott-platform": self.config["client"]["platform"], + "x-skyott-proposition": self.config["client"]["proposition"], + "x-skyott-provider": self.config["client"]["provider"], + "x-skyott-territory": self.config["client"]["territory"], + "x-skyott-usertoken": self.tokens["userToken"] + } + + body = json.dumps({ + "contentId": content_id, + "providerVariantId": variant_id, + "device": { + "capabilities": [ + { + "transport": "DASH", + "protection": "NONE", + "vcodec": "H265", + "acodec": "AAC", + "container": "ISOBMFF" + }, + { + "transport": "DASH", + "protection": "WIDEVINE", + "vcodec": "H265", + "acodec": "AAC", + "container": "ISOBMFF" + }, + { + "transport": "DASH", + "protection": "NONE", + "vcodec": "H264", + "acodec": "AAC", + "container": "ISOBMFF" + }, + { + "transport": "DASH", + "protection": "WIDEVINE", + "vcodec": "H264", + "acodec": "AAC", + "container": "ISOBMFF" + }, + + ], + "maxVideoFormat": "HD", + "hdcpEnabled": "false", + }, + "client": { + "thirdParties": [ + "COMSCORE", + "CONVIVA", + "FREEWHEEL" + ] + }, + "personaParentalControlRating": 9 + }, separators=(",", ":")) + manifest = self.session.post( + url=self.config["endpoints"]["vod"], + data=body, + headers=dict(**sky_headers, **{ + "accept": "application/vnd.playvod.v1+json", + "content-type": "application/vnd.playvod.v1+json", + "x-sky-signature": self.create_signature_header( + method="POST", + path="/video/playouts/vod", + sky_headers=sky_headers, + body=body, + timestamp=int(time.time()) + ), + }) + ).json() + + if "errorCode" in manifest: + self.log.exit(f" - An error occurred: {manifest['description']} [{manifest['errorCode']}]") + raise + + self.license_api = manifest["protection"]["licenceAcquisitionUrl"] + self.license_bt = manifest["protection"]["licenceToken"] + + tracks = Tracks.from_mpd( + url=manifest["asset"]["endpoints"][0]["url"]+'&audio=all&subtitle=all', + session=self.session, + #lang=title.original_lang, + source=self.ALIASES[0] + ) + + for track in tracks: + track.needs_proxy = True + + if self.acodec: + tracks.audios = [ + x for x in tracks.audios + if x.codec and x.codec[:4] == self.AUDIO_CODEC_MAP[self.acodec] + ] + + return tracks + + def get_chapters(self, title: Title) -> list[MenuTrack]: + return [] + + def certificate(self, challenge, **_): + return self.license(challenge) + + def license(self, challenge: bytes, **_) -> bytes: + assert self.license_api is not None + return self.session.post( + url=self.license_api, + headers={ + "Accept": "*", + "X-Sky-Signature": self.create_signature_header( + method="POST", + path="/" + self.license_api.split("://", 1)[1].split("/", 1)[1], + sky_headers={}, + body="", + timestamp=int(time.time()) + ) + }, + data=challenge # expects bytes + ).content + # Service specific functions + def configure(self) -> None: + self.session.headers.update({"Origin": "https://www.skyshowtime.com"}) + self.log.info("Getting Skyshowtime Client configuration") + if self.config["client"]["platform"] != "PC": + self.service_config = self.session.get( + url=self.config["endpoints"]["config"].format( + territory=self.config["client"]["territory"], + provider=self.config["client"]["provider"], + proposition=self.config["client"]["proposition"], + platform=self.config["client"]["platform"], + version=self.config["client"]["version"], + ) + ).json() + self.hmac_key = bytes(self.config["security"]["signature_hmac_key_v4"], "utf-8") + self.log.info("Getting Authorization Tokens") + self.tokens = self.get_tokens() + self.log.info("Verifying Authorization Tokens") + #if not self.verify_tokens(): + # self.log.info(" - Failed! Cookies might be outdated.") + # raise + + @staticmethod + def calculate_sky_header_md5(headers: dict) -> str: + if len(headers.items()) > 0: + headers_str = "\n".join(list(map(lambda x: f"{x[0].lower()}: {x[1]}", headers.items()))) + "\n" + else: + headers_str = "{}" + return str(hashlib.md5(headers_str.encode()).hexdigest()) + + @staticmethod + def calculate_body_md5(body: str) -> str: + return str(hashlib.md5(body.encode()).hexdigest()) + + def calculate_signature(self, msg: str) -> str: + digest = hmac.new(self.hmac_key, bytes(msg, "utf-8"), hashlib.sha1).digest() + return str(base64.b64encode(digest), "utf-8") + + def create_signature_header(self, method: str, path: str, sky_headers: dict, body: str, timestamp: int) -> str: + data = "\n".join([ + method.upper(), + path, + "", # important! + self.config["client"]["client_sdk"], + "1.0", + self.calculate_sky_header_md5(sky_headers), + str(timestamp), + self.calculate_body_md5(body) + ]) + "\n" + + signature_hmac = self.calculate_signature(data) + + return self.config["security"]["signature_format"].format( + client=self.config["client"]["client_sdk"], + signature=signature_hmac, + timestamp=timestamp + ) + + def get_tokens(self): + # Try to get cached tokens + tokens_cache_path = self.get_cache("tokens_{profile}_{id}.json".format( + profile=self.profile, + id=self.config["client"]["id"] + )) + if os.path.isfile(tokens_cache_path): + with open(tokens_cache_path, encoding="utf-8") as fd: + tokens = json.load(fd) + tokens_expiration = tokens.get("tokenExpiryTime", None) + if tokens_expiration and datetime.strptime(tokens_expiration, "%Y-%m-%dT%H:%M:%S.%fZ") > datetime.now(): + return tokens + + # Get all SkyOTT headers + sky_headers = { + # order of these matter! + "x-skyott-Activeterritory": self.config["client"]["activeterritory"], + "x-skyott-device": self.config["client"]["device"], + "x-skyott-language": self.config["client"]["language"], + "x-skyott-platform": self.config["client"]["platform"], + "x-skyott-proposition": self.config["client"]["proposition"], + "x-skyott-provider": self.config["client"]["provider"], + "x-skyott-territory": self.config["client"]["territory"] + } + + # Craft the body data that will be sent to the tokens endpoint, being minified and order matters! + body = json.dumps({ + "auth": { + "authScheme": self.config["client"]["auth_scheme"], + "authIssuer": self.config["client"]["auth_issuer"], + "provider": self.config["client"]["provider"], + "providerTerritory": self.config["client"]["territory"], + "proposition": self.config["client"]["proposition"], + }, + "device": { + "type": self.config["client"]["device"], + "platform": self.config["client"]["platform"], + "id": self.config["client"]["id"], + "drmDeviceId": self.config["client"]["drm_device_id"] + } + }, separators=(",", ":")) + + # Get the tokens + tokens = self.session.post( + url=self.config["endpoints"]["tokens"], + headers=dict(**sky_headers, **{ + "Accept": "application/vnd.tokens.v1+json", + "Content-Type": "application/vnd.tokens.v1+json", + "X-Sky-Signature": self.create_signature_header( + method="POST", + path="/auth/tokens", + sky_headers=sky_headers, + body=body, + timestamp=int(time.time()) + ) + }), + data=body + ).json() + + os.makedirs(os.path.dirname(tokens_cache_path), exist_ok=True) + with open(tokens_cache_path, "w", encoding="utf-8") as fd: + json.dump(tokens, fd) + + return tokens + + def verify_tokens(self) -> bool: + """Verify the tokens by calling the /auth/users/me endpoint and seeing if it works""" + sky_headers = { + # order of these matter! + "x-skyott-Activeterritory": self.config["client"]["activeterritory"], + "x-skyott-device": self.config["client"]["device"], + "x-skyott-platform": self.config["client"]["platform"], + "x-skyott-proposition": self.config["client"]["proposition"], + "x-skyott-provider": self.config["client"]["provider"], + "x-skyott-territory": self.config["client"]["territory"], + "x-skyott-usertoken": self.tokens["userToken"] + } + me = self.session.get( + url=self.config["endpoints"]["me"], + headers=dict(**sky_headers, **{ + "accept": "application/vnd.userinfo.v2+json", + "content-type": "application/vnd.userinfo.v2+json", + "x-sky-signature": self.create_signature_header( + method="GET", + path="/auth/users/me", + sky_headers=sky_headers, + body="", + timestamp=int(time.time()) + ) + }) + ) + + return me.status_code == 200 diff --git a/Services/skyshowtime.yml b/Services/skyshowtime.yml new file mode 100644 index 0000000..ccc23a3 --- /dev/null +++ b/Services/skyshowtime.yml @@ -0,0 +1,26 @@ +endpoints: + config: 'https://config.clients.skyshowtime.com/GLOBAL/{provider}/{proposition}/{platform}/PROD/{version}/config.json' + tokens: 'https://ovp.skyshowtime.com/auth/tokens' + me: 'https://ovp.skyshowtime.com/auth/users/me' + node: 'https://atom.skyshowtime.com/adapter-calypso/v3/query/node/' + vod: 'https://ovp.skyshowtime.com/video/playouts/vod' + +client: + version: '4.1.10' + config_version: '4.1.11' + territory: 'RO' + activeterritory: 'RO' + language: 'en-US' + provider: 'SKYSHOWTIME' + proposition: 'SKYSHOWTIME' + platform: 'ANDROID' + device: 'MOBILE' + id: 'Ptudy2gGV4nNa9nUyFbl' + drm_device_id: 'UNKNOWN' + client_sdk: 'SKYSHOWTIME-ANDROID-v1' + auth_scheme: 'MESSO' + auth_issuer: 'NOWTV' + +security: + signature_hmac_key_v4: 'jfj9qGg6aDHaBbFpH6wNEvN6cHuHtZVppHRvBgZs' + signature_format: 'SkyOTT client="{client}",signature="{signature}",timestamp="{timestamp}",version="1.0"'