add BBC iPlayer service
This commit is contained in:
parent
c33367ad7a
commit
5b5ef7ec2f
287
services/iP/__init__.py
Normal file
287
services/iP/__init__.py
Normal file
@ -0,0 +1,287 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
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 title URL or id as input:
|
||||
https://www.bbc.co.uk/iplayer/episodes/b007r3n8 OR b007r3n8
|
||||
- Downloading by episode URL is currently not supported
|
||||
|
||||
\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
|
||||
"""
|
||||
|
||||
ALIASES = ("bbciplayer", "bbc", "iplayer")
|
||||
GEOFENCE = ("gb",)
|
||||
TITLE_RE = r"^(?:https?://(?:www\.)?bbc\.co\.uk/iplayer/episodes?/)?(?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]:
|
||||
pid = re.match(self.TITLE_RE, self.title).group("id")
|
||||
if not pid:
|
||||
self.log.error("Invalid input - is the URL or id correct?")
|
||||
sys.exit(1)
|
||||
|
||||
data = self.get_data(pid, slice_id=None)
|
||||
if data is None:
|
||||
self.log.error("Metadata was not found - is the URL or id correct?")
|
||||
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) for season in seasons for episode in season["entities"]["results"]]
|
||||
return Series(episodes)
|
||||
|
||||
def get_tracks(self, title: Union[Movie, Episode]) -> Tracks:
|
||||
playlist = self.session.get(url=self.config["endpoints"]["playlist"].format(pid=title.id)).json()
|
||||
if not playlist["defaultAvailableVersion"]:
|
||||
self.log.error(" - Title is unavailable")
|
||||
sys.exit(1)
|
||||
|
||||
if self.config.get("cert"):
|
||||
url = self.config["endpoints"]["manifest_"].format(
|
||||
vpid=playlist["defaultAvailableVersion"]["smpConfig"]["items"][0]["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:
|
||||
self.log.error(f" - Failed to get manifest [{manifest['result']}]")
|
||||
sys.exit(1)
|
||||
|
||||
else:
|
||||
url = self.config["endpoints"]["manifest"].format(
|
||||
vpid=playlist["defaultAvailableVersion"]["smpConfig"]["items"][0]["vpid"],
|
||||
mediaset="iptv-all",
|
||||
)
|
||||
manifest = self.session.get(url).json()
|
||||
|
||||
connection = {}
|
||||
for video in [x for x in manifest["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 manifest["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 create_episode(self, 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"))
|
||||
season_num = int(next((m.group(1) or m.group(2) or m.group(3).replace("/", "") for m in series), 0))
|
||||
|
||||
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",
|
||||
)
|
||||
|
||||
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)
|
9
services/iP/config.yaml
Normal file
9
services/iP/config.yaml
Normal file
@ -0,0 +1,9 @@
|
||||
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