Updated @stabbedbybrick service files
This commit is contained in:
parent
aee2998d66
commit
1e58c9359f
@ -1,381 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import sys
|
||||
import re
|
||||
from collections.abc import Generator
|
||||
from datetime import datetime, timezone
|
||||
from http.cookiejar import MozillaCookieJar
|
||||
from typing import Any, Optional, Union
|
||||
|
||||
import click
|
||||
from click import Context
|
||||
from Crypto.Util.Padding import unpad
|
||||
from Cryptodome.Cipher import AES
|
||||
from pywidevine.cdm import Cdm as WidevineCdm
|
||||
|
||||
from devine.core.credential import Credential
|
||||
from devine.core.manifests.dash import DASH
|
||||
from devine.core.search_result import SearchResult
|
||||
from devine.core.service import Service
|
||||
from devine.core.titles import Episode, Movie, Movies, Series
|
||||
from devine.core.tracks import Chapter, Subtitle, Tracks
|
||||
|
||||
|
||||
class ALL4(Service):
|
||||
"""
|
||||
Service code for Channel 4's All4 streaming service (https://channel4.com).
|
||||
|
||||
\b
|
||||
Author: stabbedbybrick
|
||||
Authorization: Credentials
|
||||
Robustness:
|
||||
L3: 1080p, AAC2.0
|
||||
|
||||
\b
|
||||
Tips:
|
||||
- Use complete title URL or slug as input:
|
||||
https://www.channel4.com/programmes/taskmaster OR taskmaster
|
||||
- Use on demand URL for directly downloading episodes:
|
||||
https://www.channel4.com/programmes/taskmaster/on-demand/75588-002
|
||||
- Both android and web/pc endpoints are checked for quality profiles.
|
||||
If android is missing 1080p, it automatically falls back to web.
|
||||
"""
|
||||
|
||||
GEOFENCE = ("gb", "ie")
|
||||
TITLE_RE = r"^(?:https?://(?:www\.)?channel4\.com/programmes/)?(?P<id>[a-z0-9-]+)(?:/on-demand/(?P<vid>[0-9-]+))?"
|
||||
|
||||
@staticmethod
|
||||
@click.command(name="ALL4", short_help="https://channel4.com", help=__doc__)
|
||||
@click.argument("title", type=str)
|
||||
@click.pass_context
|
||||
def cli(ctx: Context, **kwargs: Any) -> ALL4:
|
||||
return ALL4(ctx, **kwargs)
|
||||
|
||||
def __init__(self, ctx: Context, title: str):
|
||||
self.title = title
|
||||
super().__init__(ctx)
|
||||
|
||||
self.authorization: str
|
||||
self.asset_id: int
|
||||
self.license_token: str
|
||||
self.manifest: str
|
||||
|
||||
self.session.headers.update(
|
||||
{
|
||||
"X-C4-Platform-Name": self.config["device"]["platform_name"],
|
||||
"X-C4-Device-Type": self.config["device"]["device_type"],
|
||||
"X-C4-Device-Name": self.config["device"]["device_name"],
|
||||
"X-C4-App-Version": self.config["device"]["app_version"],
|
||||
"X-C4-Optimizely-Datafile": self.config["device"]["optimizely_datafile"],
|
||||
}
|
||||
)
|
||||
|
||||
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}")
|
||||
|
||||
if cache and not cache.expired:
|
||||
# cached
|
||||
self.log.info(" + Using cached Tokens...")
|
||||
tokens = cache.data
|
||||
elif cache and cache.expired:
|
||||
# expired, refresh
|
||||
self.log.info("Refreshing cached Tokens")
|
||||
r = self.session.post(
|
||||
self.config["endpoints"]["login"],
|
||||
headers={"authorization": f"Basic {self.config['android']['auth']}"},
|
||||
data={
|
||||
"grant_type": "refresh_token",
|
||||
"username": credential.username,
|
||||
"password": credential.password,
|
||||
"refresh_token": cache.data["refreshToken"],
|
||||
},
|
||||
)
|
||||
try:
|
||||
res = r.json()
|
||||
except json.JSONDecodeError:
|
||||
raise ValueError(f"Failed to refresh tokens: {r.text}")
|
||||
|
||||
if "error" in res:
|
||||
self.log.error(f"Failed to refresh tokens: {res['errorMessage']}")
|
||||
sys.exit(1)
|
||||
|
||||
tokens = res
|
||||
self.log.info(" + Refreshed")
|
||||
else:
|
||||
# new
|
||||
headers = {"authorization": f"Basic {self.config['android']['auth']}"}
|
||||
data = {
|
||||
"grant_type": "password",
|
||||
"username": credential.username,
|
||||
"password": credential.password,
|
||||
}
|
||||
r = self.session.post(self.config["endpoints"]["login"], headers=headers, data=data)
|
||||
try:
|
||||
res = r.json()
|
||||
except json.JSONDecodeError:
|
||||
raise ValueError(f"Failed to log in: {r.text}")
|
||||
|
||||
if "error" in res:
|
||||
self.log.error(f"Failed to log in: {res['errorMessage']}")
|
||||
sys.exit(1)
|
||||
|
||||
tokens = res
|
||||
self.log.info(" + Acquired tokens...")
|
||||
|
||||
cache.set(tokens, expiration=tokens["expiresIn"])
|
||||
|
||||
self.authorization = f"Bearer {tokens['accessToken']}"
|
||||
|
||||
def search(self) -> Generator[SearchResult, None, None]:
|
||||
params = {
|
||||
"expand": "default",
|
||||
"q": self.title,
|
||||
"limit": "100",
|
||||
"offset": "0",
|
||||
}
|
||||
|
||||
r = self.session.get(self.config["endpoints"]["search"], params=params)
|
||||
r.raise_for_status()
|
||||
|
||||
results = r.json()
|
||||
if isinstance(results["results"], list):
|
||||
for result in results["results"]:
|
||||
yield SearchResult(
|
||||
id_=result["brand"].get("websafeTitle"),
|
||||
title=result["brand"].get("title"),
|
||||
description=result["brand"].get("description"),
|
||||
label=result.get("label"),
|
||||
url=result["brand"].get("href"),
|
||||
)
|
||||
|
||||
def get_titles(self) -> Union[Movies, Series]:
|
||||
title, on_demand = (re.match(self.TITLE_RE, self.title).group(i) for i in ("id", "vid"))
|
||||
|
||||
r = self.session.get(
|
||||
self.config["endpoints"]["title"].format(title=title),
|
||||
params={"client": "android-mod", "deviceGroup": "mobile", "include": "extended-restart"},
|
||||
headers={"Authorization": self.authorization},
|
||||
)
|
||||
if not r.ok:
|
||||
self.log.error(r.text)
|
||||
sys.exit(1)
|
||||
|
||||
data = r.json()
|
||||
|
||||
if on_demand is not None:
|
||||
return Series(
|
||||
[
|
||||
Episode(
|
||||
id_=episode["programmeId"],
|
||||
service=self.__class__,
|
||||
title=data["brand"]["title"],
|
||||
season=episode["seriesNumber"],
|
||||
number=episode["episodeNumber"],
|
||||
name=episode["originalTitle"],
|
||||
language="en",
|
||||
data=episode["assetInfo"].get("streaming"),
|
||||
)
|
||||
for episode in data["brand"]["episodes"]
|
||||
if episode.get("assetInfo") and episode["programmeId"] == on_demand
|
||||
]
|
||||
)
|
||||
|
||||
elif data["brand"]["programmeType"] == "FM":
|
||||
return Movies(
|
||||
[
|
||||
Movie(
|
||||
id_=movie["programmeId"],
|
||||
service=self.__class__,
|
||||
name=data["brand"]["title"],
|
||||
year=int(data["brand"]["summary"].split(" ")[0].strip().strip("()")),
|
||||
language="en",
|
||||
data=movie["assetInfo"].get("streaming"),
|
||||
)
|
||||
for movie in data["brand"]["episodes"]
|
||||
]
|
||||
)
|
||||
else:
|
||||
return Series(
|
||||
[
|
||||
Episode(
|
||||
id_=episode["programmeId"],
|
||||
service=self.__class__,
|
||||
title=data["brand"]["title"],
|
||||
season=episode["seriesNumber"],
|
||||
number=episode["episodeNumber"],
|
||||
name=episode["originalTitle"],
|
||||
language="en",
|
||||
data=episode["assetInfo"].get("streaming"),
|
||||
)
|
||||
for episode in data["brand"]["episodes"]
|
||||
if episode.get("assetInfo")
|
||||
]
|
||||
)
|
||||
|
||||
def get_tracks(self, title: Union[Movie, Episode]) -> Tracks:
|
||||
android_assets: tuple = self.android_playlist(title.id)
|
||||
web_assets: tuple = self.web_playlist(title.id)
|
||||
self.manifest, self.license_token, subtitle, data = self.sort_assets(android_assets, web_assets)
|
||||
self.asset_id = int(title.data["assetId"])
|
||||
|
||||
tracks = DASH.from_url(self.manifest, self.session).to_tracks(title.language)
|
||||
tracks.videos[0].data = data
|
||||
|
||||
if subtitle is not None:
|
||||
tracks.add(
|
||||
Subtitle(
|
||||
id_=hashlib.md5(subtitle.encode()).hexdigest()[0:6],
|
||||
url=subtitle,
|
||||
codec=Subtitle.Codec.from_mime(subtitle[-3:]),
|
||||
language=title.language,
|
||||
is_original_lang=True,
|
||||
forced=False,
|
||||
sdh=True,
|
||||
)
|
||||
)
|
||||
|
||||
for track in tracks.audio:
|
||||
role = track.data["dash"]["representation"].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]) -> list[Chapter]:
|
||||
track = title.tracks.videos[0]
|
||||
|
||||
chapters = [
|
||||
Chapter(
|
||||
name=f"Chapter {i + 1:02}",
|
||||
timestamp=datetime.fromtimestamp((ms / 1000), tz=timezone.utc).strftime("%H:%M:%S.%f")[:-3],
|
||||
)
|
||||
for i, ms in enumerate(x["breakOffset"] for x in track.data["adverts"]["breaks"])
|
||||
]
|
||||
|
||||
if track.data.get("endCredits", {}).get("squeezeIn"):
|
||||
chapters.append(
|
||||
Chapter(
|
||||
name="Credits",
|
||||
timestamp=datetime.fromtimestamp(
|
||||
(track.data["endCredits"]["squeezeIn"] / 1000), tz=timezone.utc
|
||||
).strftime("%H:%M:%S.%f")[:-3],
|
||||
)
|
||||
)
|
||||
|
||||
return chapters
|
||||
|
||||
def get_widevine_service_certificate(self, **_: Any) -> str:
|
||||
return WidevineCdm.common_privacy_cert
|
||||
|
||||
def get_widevine_license(self, challenge: bytes, **_: Any) -> str:
|
||||
payload = {
|
||||
"message": base64.b64encode(challenge).decode("utf8"),
|
||||
"token": self.license_token,
|
||||
"request_id": self.asset_id,
|
||||
"video": {"type": "ondemand", "url": self.manifest},
|
||||
}
|
||||
|
||||
r = self.session.post(self.config["endpoints"]["license"], json=payload)
|
||||
if not r.ok:
|
||||
raise ConnectionError(f"License request failed: {r.json()['status']['type']}")
|
||||
|
||||
return r.json()["license"]
|
||||
|
||||
# Service specific functions
|
||||
|
||||
def sort_assets(self, android_assets: tuple, web_assets: tuple) -> tuple:
|
||||
if android_assets is not None:
|
||||
try:
|
||||
a_manifest, a_token, a_subtitle, data = android_assets
|
||||
android_tracks = DASH.from_url(a_manifest, self.session).to_tracks("en")
|
||||
android_heights = sorted([int(track.height) for track in android_tracks.videos], reverse=True)
|
||||
except Exception:
|
||||
android_heights = None
|
||||
|
||||
if web_assets is not None:
|
||||
try:
|
||||
b_manifest, b_token, b_subtitle, data = web_assets
|
||||
session = self.session
|
||||
session.headers.update(self.config["headers"])
|
||||
web_tracks = DASH.from_url(b_manifest, session).to_tracks("en")
|
||||
web_heights = sorted([int(track.height) for track in web_tracks.videos], reverse=True)
|
||||
except Exception:
|
||||
web_heights = None
|
||||
|
||||
if not android_heights and not web_heights:
|
||||
self.log.error("Failed to request manifest data. If you're behind a VPN/proxy, you might be blocked")
|
||||
sys.exit(1)
|
||||
|
||||
if not android_heights or android_heights[0] < 1080:
|
||||
lic_token = self.decrypt_token(b_token, client="WEB")
|
||||
return b_manifest, lic_token, b_subtitle, data
|
||||
else:
|
||||
lic_token = self.decrypt_token(a_token, client="ANDROID")
|
||||
return a_manifest, lic_token, a_subtitle, data
|
||||
|
||||
def android_playlist(self, video_id: str) -> tuple:
|
||||
url = self.config["android"]["vod"].format(video_id=video_id)
|
||||
headers = {"authorization": self.authorization}
|
||||
|
||||
r = self.session.get(url=url, headers=headers)
|
||||
if not r.ok:
|
||||
self.log.warning("Request for Android endpoint returned %s", r)
|
||||
return
|
||||
|
||||
data = json.loads(r.content)
|
||||
manifest = data["videoProfiles"][0]["streams"][0]["uri"]
|
||||
token = data["videoProfiles"][0]["streams"][0]["token"]
|
||||
subtitle = next(
|
||||
(x["url"] for x in data["subtitlesAssets"] if x["url"].endswith(".vtt")),
|
||||
None,
|
||||
)
|
||||
|
||||
return manifest, token, subtitle, data
|
||||
|
||||
def web_playlist(self, video_id: str) -> tuple:
|
||||
url = self.config["web"]["vod"].format(programmeId=video_id)
|
||||
r = self.session.get(url, headers=self.config["headers"])
|
||||
if not r.ok:
|
||||
self.log.warning("Request for WEB endpoint returned %s", r)
|
||||
return
|
||||
|
||||
data = json.loads(r.content)
|
||||
|
||||
for item in data["videoProfiles"]:
|
||||
if item["name"] == "dashwv-dyn-stream-1":
|
||||
token = item["streams"][0]["token"]
|
||||
manifest = item["streams"][0]["uri"]
|
||||
|
||||
subtitle = next(
|
||||
(x["url"] for x in data["subtitlesAssets"] if x["url"].endswith(".vtt")),
|
||||
None,
|
||||
)
|
||||
|
||||
return manifest, token, subtitle, data
|
||||
|
||||
def decrypt_token(self, token: str, client: str) -> tuple:
|
||||
if client == "ANDROID":
|
||||
key = self.config["android"]["key"]
|
||||
iv = self.config["android"]["iv"]
|
||||
|
||||
if client == "WEB":
|
||||
key = self.config["web"]["key"]
|
||||
iv = self.config["web"]["iv"]
|
||||
|
||||
if isinstance(token, str):
|
||||
token = base64.b64decode(token)
|
||||
cipher = AES.new(
|
||||
key=base64.b64decode(key),
|
||||
iv=base64.b64decode(iv),
|
||||
mode=AES.MODE_CBC,
|
||||
)
|
||||
data = unpad(cipher.decrypt(token), AES.block_size)
|
||||
dec_token = data.decode().split("|")[1]
|
||||
return dec_token.strip()
|
@ -1,27 +0,0 @@
|
||||
headers:
|
||||
Accept-Language: en-US,en;q=0.8
|
||||
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/77.0.3865.75 Safari/537.36
|
||||
|
||||
endpoints:
|
||||
login: https://api.channel4.com/online/v2/auth/token
|
||||
title: https://api.channel4.com/online/v1/views/content-hubs/{title}.json
|
||||
license: https://c4.eme.lp.aws.redbeemedia.com/wvlicenceproxy-service/widevine/acquire
|
||||
search: https://all4nav.channel4.com/v1/api/search
|
||||
|
||||
android:
|
||||
key: QVlESUQ4U0RGQlA0TThESA=="
|
||||
iv: MURDRDAzODNES0RGU0w4Mg=="
|
||||
auth: MzZVVUN0OThWTVF2QkFnUTI3QXU4ekdIbDMxTjlMUTE6Sllzd3lIdkdlNjJWbGlrVw==
|
||||
vod: https://api.channel4.com/online/v1/vod/stream/{video_id}?client=android-mod
|
||||
|
||||
web:
|
||||
key: bjljTGllWWtxd3pOQ3F2aQ==
|
||||
iv: b2R6Y1UzV2RVaVhMdWNWZA==
|
||||
vod: https://www.channel4.com/vod/stream/{programmeId}
|
||||
|
||||
device:
|
||||
platform_name: android
|
||||
device_type: mobile
|
||||
device_name: "Sony C6903 (C6903)"
|
||||
app_version: "android_app:9.4.2"
|
||||
optimizely_datafile: "2908"
|
@ -1,364 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from collections.abc import Generator
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from http.cookiejar import CookieJar
|
||||
from typing import Any, Optional
|
||||
|
||||
import click
|
||||
from pywidevine.cdm import Cdm as WidevineCdm
|
||||
|
||||
from devine.core.credential import Credential
|
||||
from devine.core.manifests import DASH
|
||||
from devine.core.search_result import SearchResult
|
||||
from devine.core.service import Service
|
||||
from devine.core.titles import Episode, Movie, Movies, Series, Title_T, Titles_T
|
||||
from devine.core.tracks import Chapter, Subtitle, Tracks
|
||||
|
||||
|
||||
class CTV(Service):
|
||||
"""
|
||||
Service code for CTV.ca (https://www.ctv.ca)
|
||||
|
||||
\b
|
||||
Author: stabbedbybrick
|
||||
Authorization: Credentials for subscription, none for freely available titles
|
||||
Robustness:
|
||||
Widevine:
|
||||
L3: 1080p, DD5.1
|
||||
|
||||
\b
|
||||
Tips:
|
||||
- Input can be either complete title/episode URL or just the path:
|
||||
/shows/young-sheldon
|
||||
/shows/young-sheldon/baptists-catholics-and-an-attempted-drowning-s7e6
|
||||
/movies/war-for-the-planet-of-the-apes
|
||||
"""
|
||||
|
||||
TITLE_RE = r"^(?:https?://(?:www\.)?ctv\.ca(?:/[a-z]{2})?)?/(?P<type>movies|shows)/(?P<id>[a-z0-9-]+)(?:/(?P<episode>[a-z0-9-]+))?$"
|
||||
GEOFENCE = ("ca",)
|
||||
|
||||
@staticmethod
|
||||
@click.command(name="CTV", short_help="https://www.ctv.ca", help=__doc__)
|
||||
@click.argument("title", type=str)
|
||||
@click.pass_context
|
||||
def cli(ctx, **kwargs):
|
||||
return CTV(ctx, **kwargs)
|
||||
|
||||
def __init__(self, ctx, title):
|
||||
self.title = title
|
||||
super().__init__(ctx)
|
||||
|
||||
self.authorization: str = None
|
||||
|
||||
self.api = self.config["endpoints"]["api"]
|
||||
self.license_url = self.config["endpoints"]["license"]
|
||||
|
||||
def authenticate(self, cookies: Optional[CookieJar] = None, credential: Optional[Credential] = None) -> None:
|
||||
super().authenticate(cookies, credential)
|
||||
if credential:
|
||||
cache = self.cache.get(f"tokens_{credential.sha1}")
|
||||
|
||||
if cache and not cache.expired:
|
||||
# cached
|
||||
self.log.info(" + Using cached Tokens...")
|
||||
tokens = cache.data
|
||||
elif cache and cache.expired:
|
||||
# expired, refresh
|
||||
self.log.info("Refreshing cached Tokens")
|
||||
r = self.session.post(
|
||||
self.config["endpoints"]["login"],
|
||||
headers={"authorization": f"Basic {self.config['endpoints']['auth']}"},
|
||||
data={
|
||||
"grant_type": "refresh_token",
|
||||
"username": credential.username,
|
||||
"password": credential.password,
|
||||
"refresh_token": cache.data["refresh_token"],
|
||||
},
|
||||
)
|
||||
try:
|
||||
res = r.json()
|
||||
except json.JSONDecodeError:
|
||||
raise ValueError(f"Failed to refresh tokens: {r.text}")
|
||||
|
||||
tokens = res
|
||||
self.log.info(" + Refreshed")
|
||||
else:
|
||||
# new
|
||||
r = self.session.post(
|
||||
self.config["endpoints"]["login"],
|
||||
headers={"authorization": f"Basic {self.config['endpoints']['auth']}"},
|
||||
data={
|
||||
"grant_type": "password",
|
||||
"username": credential.username,
|
||||
"password": credential.password,
|
||||
},
|
||||
)
|
||||
try:
|
||||
res = r.json()
|
||||
except json.JSONDecodeError:
|
||||
raise ValueError(f"Failed to log in: {r.text}")
|
||||
|
||||
tokens = res
|
||||
self.log.info(" + Acquired tokens...")
|
||||
|
||||
cache.set(tokens, expiration=tokens["expires_in"])
|
||||
|
||||
self.authorization = f"Bearer {tokens['access_token']}"
|
||||
|
||||
def search(self) -> Generator[SearchResult, None, None]:
|
||||
payload = {
|
||||
"operationName": "searchMedia",
|
||||
"variables": {"title": f"{self.title}"},
|
||||
"query": """
|
||||
query searchMedia($title: String!) {searchMedia(titleMatches: $title) {
|
||||
... on Medias {page {items {title\npath}}}}}, """,
|
||||
}
|
||||
|
||||
r = self.session.post(self.config["endpoints"]["search"], json=payload)
|
||||
if r.status_code != 200:
|
||||
self.log.error(r.text)
|
||||
return
|
||||
|
||||
for result in r.json()["data"]["searchMedia"]["page"]["items"]:
|
||||
yield SearchResult(
|
||||
id_=result.get("path"),
|
||||
title=result.get("title"),
|
||||
description=result.get("description"),
|
||||
label=result["path"].split("/")[1],
|
||||
url="https://www.ctv.ca" + result.get("path"),
|
||||
)
|
||||
|
||||
def get_titles(self) -> Titles_T:
|
||||
title, kind, episode = (re.match(self.TITLE_RE, self.title).group(i) for i in ("id", "type", "episode"))
|
||||
title_path = self.get_title_id(kind, title, episode)
|
||||
|
||||
if episode is not None:
|
||||
data = self.get_episode_data(title_path)
|
||||
return Series(
|
||||
[
|
||||
Episode(
|
||||
id_=data["axisId"],
|
||||
service=self.__class__,
|
||||
title=data["axisMedia"]["title"],
|
||||
season=int(data["seasonNumber"]),
|
||||
number=int(data["episodeNumber"]),
|
||||
name=data["title"],
|
||||
year=data.get("firstAirYear"),
|
||||
language=data["axisPlaybackLanguages"][0].get("language", "en"),
|
||||
data=data["axisPlaybackLanguages"][0]["destinationCode"],
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
if kind == "shows":
|
||||
data = self.get_series_data(title_path)
|
||||
titles = self.fetch_episodes(data["contentData"]["seasons"])
|
||||
return Series(
|
||||
[
|
||||
Episode(
|
||||
id_=episode["axisId"],
|
||||
service=self.__class__,
|
||||
title=data["contentData"]["title"],
|
||||
season=int(episode["seasonNumber"]),
|
||||
number=int(episode["episodeNumber"]),
|
||||
name=episode["title"],
|
||||
year=data["contentData"]["firstAirYear"],
|
||||
language=episode["axisPlaybackLanguages"][0].get("language", "en"),
|
||||
data=episode["axisPlaybackLanguages"][0]["destinationCode"],
|
||||
)
|
||||
for episode in titles
|
||||
]
|
||||
)
|
||||
|
||||
if kind == "movies":
|
||||
data = self.get_movie_data(title_path)
|
||||
return Movies(
|
||||
[
|
||||
Movie(
|
||||
id_=data["contentData"]["firstPlayableContent"]["axisId"],
|
||||
service=self.__class__,
|
||||
name=data["contentData"]["title"],
|
||||
year=data["contentData"]["firstAirYear"],
|
||||
language=data["contentData"]["firstPlayableContent"]["axisPlaybackLanguages"][0].get(
|
||||
"language", "en"
|
||||
),
|
||||
data=data["contentData"]["firstPlayableContent"]["axisPlaybackLanguages"][0]["destinationCode"],
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
def get_tracks(self, title: Title_T) -> Tracks:
|
||||
base = f"https://capi.9c9media.com/destinations/{title.data}/platforms/desktop"
|
||||
|
||||
r = self.session.get(f"{base}/contents/{title.id}/contentPackages")
|
||||
r.raise_for_status()
|
||||
|
||||
pkg_id = r.json()["Items"][0]["Id"]
|
||||
base += "/playback/contents"
|
||||
|
||||
manifest = f"{base}/{title.id}/contentPackages/{pkg_id}/manifest.mpd?filter=25"
|
||||
subtitle = f"{base}/{title.id}/contentPackages/{pkg_id}/manifest.vtt"
|
||||
|
||||
if self.authorization:
|
||||
self.session.headers.update({"authorization": self.authorization})
|
||||
|
||||
tracks = DASH.from_url(url=manifest, session=self.session).to_tracks(language=title.language)
|
||||
tracks.add(
|
||||
Subtitle(
|
||||
id_=hashlib.md5(subtitle.encode()).hexdigest()[0:6],
|
||||
url=subtitle,
|
||||
codec=Subtitle.Codec.from_mime(subtitle[-3:]),
|
||||
language=title.language,
|
||||
is_original_lang=True,
|
||||
forced=False,
|
||||
sdh=True,
|
||||
)
|
||||
)
|
||||
return tracks
|
||||
|
||||
def get_chapters(self, title: Title_T) -> list[Chapter]:
|
||||
return [] # Chapters not available
|
||||
|
||||
def get_widevine_service_certificate(self, **_: Any) -> str:
|
||||
return WidevineCdm.common_privacy_cert
|
||||
|
||||
def get_widevine_license(self, challenge: bytes, **_: Any) -> bytes:
|
||||
r = self.session.post(url=self.license_url, data=challenge)
|
||||
if r.status_code != 200:
|
||||
self.log.error(r.text)
|
||||
sys.exit(1)
|
||||
return r.content
|
||||
|
||||
# service specific functions
|
||||
|
||||
def get_title_id(self, kind: str, title: tuple, episode: str) -> str:
|
||||
if episode is not None:
|
||||
title += f"/{episode}"
|
||||
payload = {
|
||||
"operationName": "resolvePath",
|
||||
"variables": {"path": f"{kind}/{title}"},
|
||||
"query": """
|
||||
query resolvePath($path: String!) {
|
||||
resolvedPath(path: $path) {
|
||||
lastSegment {
|
||||
content {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
""",
|
||||
}
|
||||
r = self.session.post(self.api, json=payload).json()
|
||||
return r["data"]["resolvedPath"]["lastSegment"]["content"]["id"]
|
||||
|
||||
def get_series_data(self, title_id: str) -> json:
|
||||
payload = {
|
||||
"operationName": "axisMedia",
|
||||
"variables": {"axisMediaId": f"{title_id}"},
|
||||
"query": """
|
||||
query axisMedia($axisMediaId: ID!) {
|
||||
contentData: axisMedia(id: $axisMediaId) {
|
||||
title
|
||||
description
|
||||
originalSpokenLanguage
|
||||
mediaType
|
||||
firstAirYear
|
||||
seasons {
|
||||
title
|
||||
id
|
||||
seasonNumber
|
||||
}
|
||||
}
|
||||
}
|
||||
""",
|
||||
}
|
||||
|
||||
return self.session.post(self.api, json=payload).json()["data"]
|
||||
|
||||
def get_movie_data(self, title_id: str) -> json:
|
||||
payload = {
|
||||
"operationName": "axisMedia",
|
||||
"variables": {"axisMediaId": f"{title_id}"},
|
||||
"query": """
|
||||
query axisMedia($axisMediaId: ID!) {
|
||||
contentData: axisMedia(id: $axisMediaId) {
|
||||
title
|
||||
description
|
||||
firstAirYear
|
||||
firstPlayableContent {
|
||||
axisId
|
||||
axisPlaybackLanguages {
|
||||
destinationCode
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
""",
|
||||
}
|
||||
|
||||
return self.session.post(self.api, json=payload).json()["data"]
|
||||
|
||||
def get_episode_data(self, title_path: str) -> json:
|
||||
payload = {
|
||||
"operationName": "axisContent",
|
||||
"variables": {"id": f"{title_path}"},
|
||||
"query": """
|
||||
query axisContent($id: ID!) {
|
||||
axisContent(id: $id) {
|
||||
axisId
|
||||
title
|
||||
description
|
||||
contentType
|
||||
seasonNumber
|
||||
episodeNumber
|
||||
axisMedia {
|
||||
title
|
||||
}
|
||||
axisPlaybackLanguages {
|
||||
language
|
||||
destinationCode
|
||||
}
|
||||
}
|
||||
}
|
||||
""",
|
||||
}
|
||||
return self.session.post(self.api, json=payload).json()["data"]["axisContent"]
|
||||
|
||||
def fetch_episode(self, episode: str) -> json:
|
||||
payload = {
|
||||
"operationName": "season",
|
||||
"variables": {"seasonId": f"{episode}"},
|
||||
"query": """
|
||||
query season($seasonId: ID!) {
|
||||
axisSeason(id: $seasonId) {
|
||||
episodes {
|
||||
axisId
|
||||
title
|
||||
description
|
||||
contentType
|
||||
seasonNumber
|
||||
episodeNumber
|
||||
axisPlaybackLanguages {
|
||||
language
|
||||
destinationCode
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
""",
|
||||
}
|
||||
response = self.session.post(self.api, json=payload)
|
||||
return response.json()["data"]["axisSeason"]["episodes"]
|
||||
|
||||
def fetch_episodes(self, data: dict) -> list:
|
||||
"""TODO: Switch to async once https proxies are fully supported"""
|
||||
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]
|
@ -1,6 +0,0 @@
|
||||
endpoints:
|
||||
login: https://account.bellmedia.ca/api/login/v2.1
|
||||
auth: Y3R2LXdlYjpkZWZhdWx0
|
||||
api: https://api.ctv.ca/space-graphql/graphql
|
||||
license: https://license.9c9media.ca/widevine
|
||||
search: https://www.ctv.ca/space-graphql/apq/graphql
|
@ -1,249 +0,0 @@
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from collections.abc import Generator
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import datetime, timezone
|
||||
from http.cookiejar import CookieJar
|
||||
from typing import Any, Optional
|
||||
from urllib.parse import unquote, urlparse
|
||||
|
||||
import click
|
||||
import requests
|
||||
|
||||
from devine.core.credential import Credential
|
||||
from devine.core.manifests import DASH
|
||||
from devine.core.search_result import SearchResult
|
||||
from devine.core.service import Service
|
||||
from devine.core.titles import Episode, Movie, Movies, Series, Title_T, Titles_T
|
||||
from devine.core.tracks import Chapter, Tracks
|
||||
|
||||
|
||||
class ROKU(Service):
|
||||
"""
|
||||
Service code for The Roku Channel (https://therokuchannel.roku.com)
|
||||
|
||||
\b
|
||||
Author: stabbedbybrick
|
||||
Authorization: Cookies (optional)
|
||||
Robustness:
|
||||
Widevine:
|
||||
L3: 1080p, DD5.1
|
||||
|
||||
\b
|
||||
Tips:
|
||||
- Use complete title/episode URL or id as input:
|
||||
https://therokuchannel.roku.com/details/e05fc677ab9c5d5e8332f123770697b9/paddington
|
||||
OR
|
||||
e05fc677ab9c5d5e8332f123770697b9
|
||||
- Supports movies, series, and single episodes
|
||||
- Search is geofenced
|
||||
"""
|
||||
|
||||
GEOFENCE = ("us",)
|
||||
TITLE_RE = r"^(?:https?://(?:www.)?therokuchannel.roku.com/(?:details|watch)/)?(?P<id>[a-z0-9-]+)"
|
||||
|
||||
@staticmethod
|
||||
@click.command(name="ROKU", short_help="https://therokuchannel.roku.com", help=__doc__)
|
||||
@click.argument("title", type=str)
|
||||
@click.pass_context
|
||||
def cli(ctx, **kwargs):
|
||||
return ROKU(ctx, **kwargs)
|
||||
|
||||
def __init__(self, ctx, title):
|
||||
self.title = re.match(self.TITLE_RE, title).group("id")
|
||||
super().__init__(ctx)
|
||||
|
||||
self.license: str
|
||||
|
||||
def authenticate(
|
||||
self,
|
||||
cookies: Optional[CookieJar] = None,
|
||||
credential: Optional[Credential] = None,
|
||||
) -> None:
|
||||
super().authenticate(cookies, credential)
|
||||
if cookies is not None:
|
||||
self.session.cookies.update(cookies)
|
||||
|
||||
def search(self) -> Generator[SearchResult, None, None]:
|
||||
token = self.session.get(self.config["endpoints"]["token"]).json()["csrf"]
|
||||
|
||||
headers = {"csrf-token": token}
|
||||
payload = {"query": self.title}
|
||||
|
||||
r = self.session.post(self.config["endpoints"]["search"], headers=headers, json=payload)
|
||||
r.raise_for_status()
|
||||
|
||||
results = r.json()
|
||||
for result in results["view"]:
|
||||
if result["content"]["type"] not in ["zone", "provider"]:
|
||||
_id = result["content"].get("meta", {}).get("id")
|
||||
_desc = result["content"].get("descriptions", {})
|
||||
|
||||
label = f'{result["content"].get("type")} ({result["content"].get("releaseYear")})'
|
||||
if result["content"].get("viewOptions"):
|
||||
label += f' ({result["content"]["viewOptions"][0].get("priceDisplay")})'
|
||||
|
||||
title = re.sub(r"^-|-$", "", re.sub(r"\W+", "-", result["content"].get("title").lower()))
|
||||
|
||||
yield SearchResult(
|
||||
id_=_id,
|
||||
title=title,
|
||||
description=_desc["250"]["text"] if _desc.get("250") else None,
|
||||
label=label,
|
||||
url=f"https://therokuchannel.roku.com/details/{_id}/{title}",
|
||||
)
|
||||
|
||||
def get_titles(self) -> Titles_T:
|
||||
data = self.session.get(self.config["endpoints"]["content"] + self.title).json()
|
||||
if not data["isAvailable"]:
|
||||
self.log.error("This title is temporarily unavailable or expired")
|
||||
sys.exit(1)
|
||||
|
||||
if data["type"] == "movie":
|
||||
return Movies(
|
||||
[
|
||||
Movie(
|
||||
id_=data["meta"]["id"],
|
||||
service=self.__class__,
|
||||
name=data["title"],
|
||||
year=data["releaseYear"],
|
||||
language=data["viewOptions"][0]["media"].get("originalAudioLanguage", "en"),
|
||||
data=None,
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
elif data["type"] == "series":
|
||||
episodes = self.fetch_episodes(data)
|
||||
return Series(
|
||||
[
|
||||
Episode(
|
||||
id_=episode["meta"]["id"],
|
||||
service=self.__class__,
|
||||
title=data["title"],
|
||||
season=int(episode["seasonNumber"]),
|
||||
number=int(episode["episodeNumber"]),
|
||||
name=episode["title"],
|
||||
year=data["releaseYear"],
|
||||
language=episode["viewOptions"][0]["media"].get("originalAudioLanguage", "en"),
|
||||
data=None,
|
||||
)
|
||||
for episode in episodes
|
||||
]
|
||||
)
|
||||
|
||||
elif data["type"] == "episode":
|
||||
return Series(
|
||||
[
|
||||
Episode(
|
||||
id_=data["meta"]["id"],
|
||||
service=self.__class__,
|
||||
title=data["title"],
|
||||
season=int(data["seasonNumber"]),
|
||||
number=int(data["episodeNumber"]),
|
||||
name=data["title"],
|
||||
year=data["releaseYear"],
|
||||
language=data["viewOptions"][0]["media"].get("originalAudioLanguage", "en"),
|
||||
data=None,
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
def get_tracks(self, title: Title_T) -> Tracks:
|
||||
token = self.session.get(self.config["endpoints"]["token"]).json()["csrf"]
|
||||
|
||||
headers = {
|
||||
"csrf-token": token,
|
||||
}
|
||||
payload = {
|
||||
"rokuId": title.id,
|
||||
"mediaFormat": "mpeg-dash",
|
||||
"drmType": "widevine",
|
||||
"quality": "fhd",
|
||||
"providerId": "rokuavod",
|
||||
}
|
||||
|
||||
r = self.session.post(
|
||||
self.config["endpoints"]["vod"],
|
||||
headers=headers,
|
||||
json=payload,
|
||||
)
|
||||
r.raise_for_status()
|
||||
|
||||
videos = r.json()["playbackMedia"]["videos"]
|
||||
self.license = next(
|
||||
(
|
||||
x["drmParams"]["licenseServerURL"]
|
||||
for x in videos
|
||||
if x.get("drmParams") and x["drmParams"]["keySystem"] == "Widevine"
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
url = next((x["url"] for x in videos if x["streamFormat"] == "dash"), None)
|
||||
if url and "origin" in urlparse(url).query:
|
||||
url = unquote(urlparse(url).query.split("=")[1]).split("?")[0]
|
||||
|
||||
tracks = DASH.from_url(url=url).to_tracks(language=title.language)
|
||||
tracks.videos[0].data["playbackMedia"] = r.json()["playbackMedia"]
|
||||
|
||||
for track in tracks.audio:
|
||||
label = track.data["dash"]["adaptation_set"].find("Label")
|
||||
if label is not None and "description" in label.text:
|
||||
track.descriptive = True
|
||||
|
||||
for track in tracks.subtitles:
|
||||
label = track.data["dash"]["adaptation_set"].find("Label")
|
||||
if label is not None and "caption" in label.text:
|
||||
track.cc = True
|
||||
|
||||
return tracks
|
||||
|
||||
def get_chapters(self, title: Title_T) -> list[Chapter]:
|
||||
track = title.tracks.videos[0]
|
||||
|
||||
chapters = []
|
||||
if track.data.get("playbackMedia", {}).get("adBreaks"):
|
||||
timestamps = sorted(track.data["playbackMedia"]["adBreaks"])
|
||||
chapters = [Chapter(name=f"Chapter {i + 1:02}", timestamp=ad.split(".")[0]) for i, ad in enumerate(timestamps)]
|
||||
|
||||
if track.data.get("playbackMedia", {}).get("creditCuePoints"):
|
||||
start = next((
|
||||
x.get("start") for x in track.data["playbackMedia"]["creditCuePoints"] if x.get("start") != 0), None)
|
||||
if start:
|
||||
chapters.append(
|
||||
Chapter(
|
||||
name="Credits",
|
||||
timestamp=datetime.fromtimestamp((start / 1000), tz=timezone.utc).strftime("%H:%M:%S.%f")[:-3],
|
||||
)
|
||||
)
|
||||
|
||||
return chapters
|
||||
|
||||
def get_widevine_service_certificate(self, **_: Any) -> str:
|
||||
return # WidevineCdm.common_privacy_cert
|
||||
|
||||
def get_widevine_license(self, challenge: bytes, **_: Any) -> bytes:
|
||||
r = self.session.post(url=self.license, data=challenge)
|
||||
if r.status_code != 200:
|
||||
self.log.error(r.text)
|
||||
sys.exit(1)
|
||||
return r.content
|
||||
|
||||
# service specific functions
|
||||
|
||||
def fetch_episode(self, episode: dict) -> json:
|
||||
try:
|
||||
r = self.session.get(self.config["endpoints"]["content"] + episode["meta"]["id"])
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
except requests.exceptions.RequestException as e:
|
||||
self.log.error(f"An error occurred while fetching episode {episode['meta']['id']}: {e}")
|
||||
return None
|
||||
|
||||
def fetch_episodes(self, data: dict) -> list:
|
||||
"""TODO: Switch to async once https proxies are fully supported"""
|
||||
with ThreadPoolExecutor(max_workers=10) as executor:
|
||||
tasks = list(executor.map(self.fetch_episode, data["episodes"]))
|
||||
return [task for task in tasks if task is not None]
|
@ -1,5 +0,0 @@
|
||||
endpoints:
|
||||
content: https://therokuchannel.roku.com/api/v2/homescreen/content/https%3A%2F%2Fcontent.sr.roku.com%2Fcontent%2Fv1%2Froku-trc%2F
|
||||
vod: https://therokuchannel.roku.com/api/v3/playback
|
||||
token: https://therokuchannel.roku.com/api/v1/csrf
|
||||
search: https://therokuchannel.roku.com/api/v1/search
|
@ -1,358 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import warnings
|
||||
from collections.abc import Generator
|
||||
from typing import Any, Union
|
||||
|
||||
import click
|
||||
from bs4 import XMLParsedAsHTMLWarning
|
||||
from click import Context
|
||||
from devine.core.manifests import DASH, HLS
|
||||
from devine.core.search_result import SearchResult
|
||||
from devine.core.service import Service
|
||||
from devine.core.titles import Episode, Movie, Movies, Series
|
||||
from devine.core.tracks import Audio, Chapter, Subtitle, Track, Tracks, Video
|
||||
from devine.core.utils.collections import as_list
|
||||
from devine.core.utils.sslciphers import SSLCiphers
|
||||
|
||||
warnings.filterwarnings("ignore", category=XMLParsedAsHTMLWarning)
|
||||
|
||||
|
||||
class iP(Service):
|
||||
"""
|
||||
\b
|
||||
Service code for the BBC iPlayer streaming service (https://www.bbc.co.uk/iplayer).
|
||||
Base code from VT, credit to original author
|
||||
|
||||
\b
|
||||
Author: stabbedbybrick
|
||||
Authorization: None
|
||||
Security: None
|
||||
|
||||
\b
|
||||
Tips:
|
||||
- Use full title URL as input for best results.
|
||||
- Use --list-titles before anything, iPlayer's listings are often messed up.
|
||||
\b
|
||||
- An SSL certificate (PEM) is required for accessing the UHD endpoint.
|
||||
Specify its path using the service configuration data in the root config:
|
||||
\b
|
||||
services:
|
||||
iP:
|
||||
cert: path/to/cert
|
||||
\b
|
||||
- Use -v H.265 to request UHD tracks
|
||||
- See which titles are available in UHD:
|
||||
https://www.bbc.co.uk/iplayer/help/questions/programme-availability/uhd-content
|
||||
"""
|
||||
|
||||
ALIASES = ("bbciplayer", "bbc", "iplayer")
|
||||
GEOFENCE = ("gb",)
|
||||
TITLE_RE = r"^(?:https?://(?:www\.)?bbc\.co\.uk/(?:iplayer/(?P<kind>episode|episodes)/|programmes/))?(?P<id>[a-z0-9]+)(?:/.*)?$"
|
||||
|
||||
@staticmethod
|
||||
@click.command(name="iP", short_help="https://www.bbc.co.uk/iplayer", help=__doc__)
|
||||
@click.argument("title", type=str)
|
||||
@click.pass_context
|
||||
def cli(ctx: Context, **kwargs: Any) -> iP:
|
||||
return iP(ctx, **kwargs)
|
||||
|
||||
def __init__(self, ctx: Context, title: str):
|
||||
self.title = title
|
||||
self.vcodec = ctx.parent.params.get("vcodec")
|
||||
super().__init__(ctx)
|
||||
|
||||
if self.vcodec == "H.265" and not self.config.get("cert"):
|
||||
self.log.error("H.265 cannot be selected without a certificate")
|
||||
sys.exit(1)
|
||||
|
||||
quality = ctx.parent.params.get("quality")
|
||||
if quality and quality[0] > 1080 and self.vcodec != "H.265" and self.config.get("cert"):
|
||||
self.log.info(" + Switched video codec to H.265 to be able to get 2160p video track")
|
||||
self.vcodec = "H.265"
|
||||
|
||||
def search(self) -> Generator[SearchResult, None, None]:
|
||||
params = {
|
||||
"q": self.title,
|
||||
"apikey": self.config["api_key"],
|
||||
}
|
||||
|
||||
r = self.session.get(self.config["endpoints"]["search"], params=params)
|
||||
r.raise_for_status()
|
||||
|
||||
results = r.json()
|
||||
for result in results["results"]:
|
||||
yield SearchResult(
|
||||
id_=result.get("uri").split(":")[-1],
|
||||
title=result.get("title"),
|
||||
description=result.get("synopsis"),
|
||||
label="series" if result.get("type", "") == "brand" else result.get("type"),
|
||||
url=result.get("url"),
|
||||
)
|
||||
|
||||
def get_titles(self) -> Union[Movies, Series]:
|
||||
kind, pid = (re.match(self.TITLE_RE, self.title).group(i) for i in ("kind", "id"))
|
||||
if not pid:
|
||||
self.log.error("Unable to parse title ID - is the URL or id correct?")
|
||||
sys.exit(1)
|
||||
|
||||
data = self.get_data(pid, slice_id=None)
|
||||
if data is None and kind == "episode":
|
||||
return self.get_single_episode(self.title)
|
||||
elif data is None:
|
||||
self.log.error("Metadata was not found - if %s is an episode, use full URL as input", pid)
|
||||
sys.exit(1)
|
||||
|
||||
if "Film" in data["labels"]["category"]:
|
||||
return Movies(
|
||||
[
|
||||
Movie(
|
||||
id_=data["id"],
|
||||
name=data["title"]["default"],
|
||||
year=None, # TODO
|
||||
service=self.__class__,
|
||||
language="en",
|
||||
data=data,
|
||||
)
|
||||
]
|
||||
)
|
||||
else:
|
||||
seasons = [self.get_data(pid, x["id"]) for x in data["slices"] or [{"id": None}]]
|
||||
episodes = [self.create_episode(episode, data) for season in seasons for episode in season["entities"]["results"]]
|
||||
return Series(episodes)
|
||||
|
||||
def get_tracks(self, title: Union[Movie, Episode]) -> Tracks:
|
||||
r = self.session.get(url=self.config["endpoints"]["playlist"].format(pid=title.id))
|
||||
if not r.ok:
|
||||
self.log.error(r.text)
|
||||
sys.exit(1)
|
||||
|
||||
versions = r.json().get("allAvailableVersions")
|
||||
if not versions:
|
||||
r = self.session.get(self.config["base_url"].format(type="episode", pid=title.id))
|
||||
redux = re.search("window.__IPLAYER_REDUX_STATE__ = (.*?);</script>", r.text).group(1)
|
||||
data = json.loads(redux)
|
||||
versions = [
|
||||
{"pid": x.get("id") for x in data["versions"] if not x.get("kind") == "audio-described"}
|
||||
]
|
||||
|
||||
quality = [
|
||||
connection.get("height")
|
||||
for i in (
|
||||
self.check_all_versions(version)
|
||||
for version in (x.get("pid") for x in versions)
|
||||
)
|
||||
for connection in i
|
||||
if connection.get("height")
|
||||
]
|
||||
max_quality = max((h for h in quality if h < "1080"), default=None)
|
||||
|
||||
media = next((i for i in (self.check_all_versions(version)
|
||||
for version in (x.get("pid") for x in versions))
|
||||
if any(connection.get("height") == max_quality for connection in i)), None)
|
||||
|
||||
connection = {}
|
||||
for video in [x for x in media if x["kind"] == "video"]:
|
||||
connections = sorted(video["connection"], key=lambda x: x["priority"])
|
||||
if self.vcodec == "H.265":
|
||||
connection = connections[0]
|
||||
else:
|
||||
connection = next(
|
||||
x for x in connections if x["supplier"] == "mf_akamai" and x["transferFormat"] == "dash"
|
||||
)
|
||||
|
||||
break
|
||||
|
||||
if not self.vcodec == "H.265":
|
||||
if connection["transferFormat"] == "dash":
|
||||
connection["href"] = "/".join(
|
||||
connection["href"].replace("dash", "hls").split("?")[0].split("/")[0:-1] + ["hls", "master.m3u8"]
|
||||
)
|
||||
connection["transferFormat"] = "hls"
|
||||
elif connection["transferFormat"] == "hls":
|
||||
connection["href"] = "/".join(
|
||||
connection["href"].replace(".hlsv2.ism", "").split("?")[0].split("/")[0:-1] + ["hls", "master.m3u8"]
|
||||
)
|
||||
|
||||
if connection["transferFormat"] != "hls":
|
||||
raise ValueError(f"Unsupported video media transfer format {connection['transferFormat']!r}")
|
||||
|
||||
if connection["transferFormat"] == "dash":
|
||||
tracks = DASH.from_url(url=connection["href"], session=self.session).to_tracks(language=title.language)
|
||||
elif connection["transferFormat"] == "hls":
|
||||
tracks = HLS.from_url(url=connection["href"], session=self.session).to_tracks(language=title.language)
|
||||
else:
|
||||
raise ValueError(f"Unsupported video media transfer format {connection['transferFormat']!r}")
|
||||
|
||||
for video in tracks.videos:
|
||||
# TODO: add HLG to UHD tracks
|
||||
|
||||
if any(re.search(r"-audio_\w+=\d+", x) for x in as_list(video.url)):
|
||||
# create audio stream from the video stream
|
||||
audio_url = re.sub(r"-video=\d+", "", as_list(video.url)[0])
|
||||
audio = Audio(
|
||||
# use audio_url not video url, as to ignore video bitrate in ID
|
||||
id_=hashlib.md5(audio_url.encode()).hexdigest()[0:7],
|
||||
url=audio_url,
|
||||
codec=Audio.Codec.from_codecs("mp4a"),
|
||||
language=[v.language for v in video.data["hls"]["playlist"].media][0],
|
||||
bitrate=int(self.find(r"-audio_\w+=(\d+)", as_list(video.url)[0]) or 0),
|
||||
channels=[v.channels for v in video.data["hls"]["playlist"].media][0],
|
||||
descriptive=False, # Not available
|
||||
descriptor=Track.Descriptor.HLS,
|
||||
)
|
||||
if not tracks.exists(by_id=audio.id):
|
||||
# some video streams use the same audio, so natural dupes exist
|
||||
tracks.add(audio)
|
||||
# remove audio from the video stream
|
||||
video.url = [re.sub(r"-audio_\w+=\d+", "", x) for x in as_list(video.url)][0]
|
||||
video.codec = Video.Codec.from_codecs(video.data["hls"]["playlist"].stream_info.codecs)
|
||||
video.bitrate = int(self.find(r"-video=(\d+)", as_list(video.url)[0]) or 0)
|
||||
|
||||
for caption in [x for x in media if x["kind"] == "captions"]:
|
||||
connection = sorted(caption["connection"], key=lambda x: x["priority"])[0]
|
||||
tracks.add(
|
||||
Subtitle(
|
||||
id_=hashlib.md5(connection["href"].encode()).hexdigest()[0:6],
|
||||
url=connection["href"],
|
||||
codec=Subtitle.Codec.from_codecs("ttml"),
|
||||
language=title.language,
|
||||
is_original_lang=True,
|
||||
forced=False,
|
||||
sdh=True,
|
||||
)
|
||||
)
|
||||
break
|
||||
|
||||
return tracks
|
||||
|
||||
def get_chapters(self, title: Union[Movie, Episode]) -> list[Chapter]:
|
||||
return []
|
||||
|
||||
def get_widevine_service_certificate(self, **_: Any) -> str:
|
||||
return None
|
||||
|
||||
def get_widevine_license(self, challenge: bytes, **_: Any) -> str:
|
||||
return None
|
||||
|
||||
# service specific functions
|
||||
|
||||
def get_data(self, pid: str, slice_id: str) -> dict:
|
||||
json_data = {
|
||||
"id": "9fd1636abe711717c2baf00cebb668de",
|
||||
"variables": {
|
||||
"id": pid,
|
||||
"perPage": 200,
|
||||
"page": 1,
|
||||
"sliceId": slice_id if slice_id else None,
|
||||
},
|
||||
}
|
||||
|
||||
r = self.session.post(self.config["endpoints"]["metadata"], json=json_data)
|
||||
r.raise_for_status()
|
||||
|
||||
return r.json()["data"]["programme"]
|
||||
|
||||
def check_all_versions(self, vpid: str) -> list:
|
||||
if self.config.get("cert"):
|
||||
url = self.config["endpoints"]["manifest_"].format(
|
||||
vpid=vpid,
|
||||
mediaset="iptv-uhd" if self.vcodec == "H.265" else "iptv-all",
|
||||
)
|
||||
|
||||
session = self.session
|
||||
session.mount("https://", SSLCiphers())
|
||||
session.mount("http://", SSLCiphers())
|
||||
manifest = session.get(
|
||||
url, headers={"user-agent": self.config["user_agent"]}, cert=self.config["cert"]
|
||||
).json()
|
||||
|
||||
if "result" in manifest:
|
||||
return {}
|
||||
|
||||
else:
|
||||
url = self.config["endpoints"]["manifest"].format(
|
||||
vpid=vpid,
|
||||
mediaset="iptv-all",
|
||||
)
|
||||
manifest = self.session.get(url).json()
|
||||
|
||||
if "result" in manifest:
|
||||
return {}
|
||||
|
||||
return manifest["media"]
|
||||
|
||||
def create_episode(self, episode: dict, data: dict) -> Episode:
|
||||
title = episode["episode"]["title"]["default"].strip()
|
||||
subtitle = episode["episode"]["subtitle"]
|
||||
series = re.finditer(r"Series (\d+):|Season (\d+):|(\d{4}/\d{2}): Episode \d+", subtitle.get("default") or "")
|
||||
season_num = int(next((m.group(1) or m.group(2) or m.group(3).replace("/", "") for m in series), 0))
|
||||
if season_num == 0 and not data.get("slices"):
|
||||
season_num = 1
|
||||
|
||||
number = re.finditer(r"(\d+)\.|Episode (\d+)", subtitle.get("slice") or subtitle.get("default") or "")
|
||||
ep_num = int(next((m.group(1) or m.group(2) for m in number), 0))
|
||||
|
||||
name = re.search(r"\d+\. (.+)", subtitle.get("slice") or "")
|
||||
ep_name = name.group(1) if name else subtitle.get("slice") or ""
|
||||
if not subtitle.get("slice"):
|
||||
ep_name = subtitle.get("default") or ""
|
||||
|
||||
return Episode(
|
||||
id_=episode["episode"].get("id"),
|
||||
service=self.__class__,
|
||||
title=title,
|
||||
season=season_num,
|
||||
number=ep_num,
|
||||
name=ep_name,
|
||||
language="en",
|
||||
data=episode,
|
||||
)
|
||||
|
||||
def get_single_episode(self, url: str) -> Series:
|
||||
r = self.session.get(url)
|
||||
r.raise_for_status()
|
||||
|
||||
redux = re.search("window.__IPLAYER_REDUX_STATE__ = (.*?);</script>", r.text).group(1)
|
||||
data = json.loads(redux)
|
||||
subtitle = data["episode"].get("subtitle")
|
||||
|
||||
if subtitle is not None:
|
||||
season_match = re.search(r"Series (\d+):", subtitle)
|
||||
season = int(season_match.group(1)) if season_match else 0
|
||||
number_match = re.finditer(r"(\d+)\.|Episode (\d+)", subtitle)
|
||||
number = int(next((m.group(1) or m.group(2) for m in number_match), 0))
|
||||
name_match = re.search(r"\d+\. (.+)", subtitle)
|
||||
name = (
|
||||
name_match.group(1)
|
||||
if name_match
|
||||
else subtitle
|
||||
if not re.search(r"Series (\d+): Episode (\d+)", subtitle)
|
||||
else ""
|
||||
)
|
||||
|
||||
return Series(
|
||||
[
|
||||
Episode(
|
||||
id_=data["episode"]["id"],
|
||||
service=self.__class__,
|
||||
title=data["episode"]["title"],
|
||||
season=season if subtitle else 0,
|
||||
number=number if subtitle else 0,
|
||||
name=name if subtitle else "",
|
||||
language="en",
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
def find(self, pattern, string, group=None):
|
||||
if group:
|
||||
m = re.search(pattern, string)
|
||||
if m:
|
||||
return m.group(group)
|
||||
else:
|
||||
return next(iter(re.findall(pattern, string)), None)
|
@ -1,11 +0,0 @@
|
||||
base_url: https://www.bbc.co.uk/iplayer/{type}/{pid}
|
||||
user_agent: 'smarttv_AFTMM_Build_0003255372676_Chromium_41.0.2250.2'
|
||||
api_key: 'D2FgtcTxGqqIgLsfBWTJdrQh2tVdeaAp'
|
||||
|
||||
endpoints:
|
||||
metadata: 'https://graph.ibl.api.bbc.co.uk/'
|
||||
playlist: 'https://www.bbc.co.uk/programmes/{pid}/playlist.json'
|
||||
manifest: "https://open.live.bbc.co.uk/mediaselector/6/select/version/2.0/mediaset/{mediaset}/vpid/{vpid}/"
|
||||
manifest_: 'https://securegate.iplayer.bbc.co.uk/mediaselector/6/select/version/2.0/vpid/{vpid}/format/json/mediaset/{mediaset}/proto/https'
|
||||
search: "https://search.api.bbci.co.uk/formula/iplayer-ibl-root"
|
||||
|
Loading…
Reference in New Issue
Block a user