iP -> 1.0.1
- Fix media selection - Add cert to config - Improve season regex - General cleanup
This commit is contained in:
parent
7be7ce7b01
commit
ba9bbb302f
@ -1,12 +1,14 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
import sys
|
import tempfile
|
||||||
import warnings
|
import warnings
|
||||||
from collections.abc import Generator
|
from collections.abc import Generator
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
from pathlib import Path
|
||||||
from typing import Any, Union
|
from typing import Any, Union
|
||||||
|
|
||||||
import click
|
import click
|
||||||
@ -16,7 +18,7 @@ from devine.core.manifests import DASH, HLS
|
|||||||
from devine.core.search_result import SearchResult
|
from devine.core.search_result import SearchResult
|
||||||
from devine.core.service import Service
|
from devine.core.service import Service
|
||||||
from devine.core.titles import Episode, Movie, Movies, Series
|
from devine.core.titles import Episode, Movie, Movies, Series
|
||||||
from devine.core.tracks import Audio, Chapter, Subtitle, Tracks, Video
|
from devine.core.tracks import Audio, Chapters, Subtitle, Tracks, Video
|
||||||
from devine.core.utils.collections import as_list
|
from devine.core.utils.collections import as_list
|
||||||
from devine.core.utils.sslciphers import SSLCiphers
|
from devine.core.utils.sslciphers import SSLCiphers
|
||||||
|
|
||||||
@ -27,10 +29,9 @@ class iP(Service):
|
|||||||
"""
|
"""
|
||||||
\b
|
\b
|
||||||
Service code for the BBC iPlayer streaming service (https://www.bbc.co.uk/iplayer).
|
Service code for the BBC iPlayer streaming service (https://www.bbc.co.uk/iplayer).
|
||||||
Base code from VT, credit to original author
|
|
||||||
|
|
||||||
\b
|
\b
|
||||||
Version: 1.0.0
|
Version: 1.0.1
|
||||||
Author: stabbedbybrick
|
Author: stabbedbybrick
|
||||||
Authorization: None
|
Authorization: None
|
||||||
Security: None
|
Security: None
|
||||||
@ -39,13 +40,6 @@ class iP(Service):
|
|||||||
Tips:
|
Tips:
|
||||||
- Use full title URL as input for best results.
|
- Use full title URL as input for best results.
|
||||||
- Use --list-titles before anything, iPlayer's listings are often messed up.
|
- 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
|
\b
|
||||||
- Use --range HLG to request H.265 UHD tracks
|
- Use --range HLG to request H.265 UHD tracks
|
||||||
- See which titles are available in UHD:
|
- See which titles are available in UHD:
|
||||||
@ -60,22 +54,21 @@ class iP(Service):
|
|||||||
@click.command(name="iP", short_help="https://www.bbc.co.uk/iplayer", help=__doc__)
|
@click.command(name="iP", short_help="https://www.bbc.co.uk/iplayer", help=__doc__)
|
||||||
@click.argument("title", type=str)
|
@click.argument("title", type=str)
|
||||||
@click.pass_context
|
@click.pass_context
|
||||||
def cli(ctx: Context, **kwargs: Any) -> iP:
|
def cli(ctx: Context, **kwargs: Any) -> "iP":
|
||||||
return iP(ctx, **kwargs)
|
return iP(ctx, **kwargs)
|
||||||
|
|
||||||
def __init__(self, ctx: Context, title: str):
|
def __init__(self, ctx: Context, title: str):
|
||||||
self.title = title
|
|
||||||
super().__init__(ctx)
|
super().__init__(ctx)
|
||||||
|
self.title = title
|
||||||
self.vcodec = ctx.parent.params.get("vcodec")
|
self.vcodec = ctx.parent.params.get("vcodec")
|
||||||
self.range = ctx.parent.params.get("range_")
|
self.range = ctx.parent.params.get("range_")
|
||||||
|
|
||||||
self.session.headers.update({"user-agent": "BBCiPlayer/5.17.2.32046"})
|
self.session.headers.update({"user-agent": "BBCiPlayer/5.17.2.32046"})
|
||||||
|
|
||||||
if self.range and self.range[0].name == "HLG" and not self.config.get("cert"):
|
if self.range and self.range[0].name == "HLG":
|
||||||
self.log.error("HLG tracks cannot be requested without an SSL certificate")
|
if not self.config.get("certificate"):
|
||||||
sys.exit(1)
|
raise CertificateMissingError("HLG/H.265 tracks cannot be requested without a TLS certificate.")
|
||||||
|
|
||||||
elif self.range and self.range[0].name == "HLG":
|
|
||||||
self.session.headers.update({"user-agent": self.config["user_agent"]})
|
self.session.headers.update({"user-agent": self.config["user_agent"]})
|
||||||
self.vcodec = "H.265"
|
self.vcodec = "H.265"
|
||||||
|
|
||||||
@ -83,128 +76,189 @@ class iP(Service):
|
|||||||
r = self.session.get(self.config["endpoints"]["search"], params={"q": self.title})
|
r = self.session.get(self.config["endpoints"]["search"], params={"q": self.title})
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
|
|
||||||
results = r.json()
|
results = r.json().get("new_search", {}).get("results", [])
|
||||||
for result in results["new_search"]["results"]:
|
for result in results:
|
||||||
programme_type = result.get("type")
|
programme_type = result.get("type", "unknown")
|
||||||
category = result.get("labels", {}).get("category", "")
|
category = result.get("labels", {}).get("category", "")
|
||||||
path = "episode" if programme_type == "episode" else "episodes"
|
path = "episode" if programme_type == "episode" else "episodes"
|
||||||
yield SearchResult(
|
yield SearchResult(
|
||||||
id_=result.get("id"),
|
id_=result.get("id"),
|
||||||
title=result.get("title"),
|
title=result.get("title"),
|
||||||
description=result.get("synopses", {}).get("small"),
|
description=result.get("synopses", {}).get("small"),
|
||||||
label=programme_type + " - " + category,
|
label=f"{programme_type} - {category}",
|
||||||
url=f"https://www.bbc.co.uk/iplayer/{path}/{result.get('id')}",
|
url=f"https://www.bbc.co.uk/iplayer/{path}/{result.get('id')}",
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_titles(self) -> Union[Movies, Series]:
|
def get_titles(self) -> Union[Movies, Series]:
|
||||||
try:
|
match = re.match(self.TITLE_RE, self.title)
|
||||||
kind, pid = (re.match(self.TITLE_RE, self.title).group(i) for i in ("kind", "id"))
|
if not match:
|
||||||
except Exception:
|
raise ValueError("Could not parse ID from title - is the URL/ID format correct?")
|
||||||
raise ValueError("Could not parse ID from title - is the URL correct?")
|
|
||||||
|
|
||||||
|
groups = match.groupdict()
|
||||||
|
pid = groups.get("id")
|
||||||
|
kind = groups.get("kind")
|
||||||
|
|
||||||
|
# Attempt to get brand/series data first
|
||||||
data = self.get_data(pid, slice_id=None)
|
data = self.get_data(pid, slice_id=None)
|
||||||
|
|
||||||
|
# Handle case where the input is a direct episode URL and get_data fails
|
||||||
if data is None and kind == "episode":
|
if data is None and kind == "episode":
|
||||||
return Series([self.fetch_episode(pid)])
|
return Series([self.fetch_episode(pid)])
|
||||||
|
|
||||||
elif data is None:
|
if data is None:
|
||||||
raise ValueError(f"Metadata was not found - if {pid} is an episode, use full URL as input")
|
raise MetadataError(f"Metadata not found for '{pid}'. If it's an episode, use the full URL.")
|
||||||
|
|
||||||
|
# If it's a "series" with only one item, it might be a movie.
|
||||||
if data.get("count", 0) < 2:
|
if data.get("count", 0) < 2:
|
||||||
data = self.session.get(self.config["endpoints"]["episodes"].format(pid=pid)).json()
|
r = self.session.get(self.config["endpoints"]["episodes"].format(pid=pid))
|
||||||
if not data.get("episodes"):
|
r.raise_for_status()
|
||||||
raise ValueError(f"Metadata was not found for {pid}")
|
episodes_data = r.json()
|
||||||
|
if not episodes_data.get("episodes"):
|
||||||
movie = data.get("episodes")[0]
|
raise MetadataError(f"Episode metadata not found for '{pid}'.")
|
||||||
|
|
||||||
|
movie_data = episodes_data["episodes"][0]
|
||||||
return Movies(
|
return Movies(
|
||||||
[
|
[
|
||||||
Movie(
|
Movie(
|
||||||
id_=movie.get("id"),
|
id_=movie_data.get("id"),
|
||||||
name=movie.get("title"),
|
name=movie_data.get("title"),
|
||||||
year=movie.get("release_date_time", "").split("-")[0],
|
year=(movie_data.get("release_date_time", "") or "").split("-")[0],
|
||||||
service=self.__class__,
|
service=self.__class__,
|
||||||
language="en",
|
language="en",
|
||||||
data=data,
|
data=data,
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
else:
|
|
||||||
seasons = [self.get_data(pid, x["id"]) for x in data["slices"] or [{"id": None}]]
|
# It's a full series
|
||||||
episode_ids = [
|
seasons = [self.get_data(pid, x["id"]) for x in data.get("slices") or [{"id": None}]]
|
||||||
episode.get("episode", {}).get("id")
|
episode_ids = [
|
||||||
for season in seasons
|
episode.get("episode", {}).get("id")
|
||||||
for episode in season["entities"]["results"]
|
for season in seasons
|
||||||
if not episode.get("episode", {}).get("live")
|
for episode in season.get("entities", {}).get("results", [])
|
||||||
and episode.get("episode", {}).get("id") is not None
|
if not episode.get("episode", {}).get("live")
|
||||||
]
|
and episode.get("episode", {}).get("id")
|
||||||
episodes = self.get_episodes(episode_ids)
|
]
|
||||||
return Series(episodes)
|
|
||||||
|
episodes = self.get_episodes(episode_ids)
|
||||||
|
return Series(episodes)
|
||||||
|
|
||||||
def get_tracks(self, title: Union[Movie, Episode]) -> Tracks:
|
def get_tracks(self, title: Union[Movie, Episode]) -> Tracks:
|
||||||
r = self.session.get(url=self.config["endpoints"]["playlist"].format(pid=title.id))
|
versions = self._get_available_versions(title.id)
|
||||||
|
if not versions:
|
||||||
|
raise NoStreamsAvailableError("No available versions for this title were found.")
|
||||||
|
|
||||||
|
connections = [self.check_all_versions(version["pid"]) for version in versions]
|
||||||
|
connections = [c for c in connections if c]
|
||||||
|
if not connections:
|
||||||
|
if self.vcodec == "H.265":
|
||||||
|
raise NoStreamsAvailableError("Selection unavailable in UHD.")
|
||||||
|
raise NoStreamsAvailableError("Selection unavailable. Title may be missing or geo-blocked.")
|
||||||
|
|
||||||
|
media = self._select_best_media(connections)
|
||||||
|
if not media:
|
||||||
|
raise NoStreamsAvailableError("Could not find a suitable media stream.")
|
||||||
|
|
||||||
|
tracks = self._select_tracks(media, title.language)
|
||||||
|
|
||||||
|
return tracks
|
||||||
|
|
||||||
|
def get_chapters(self, title: Union[Movie, Episode]) -> Chapters:
|
||||||
|
return Chapters()
|
||||||
|
|
||||||
|
def _get_available_versions(self, pid: str) -> list[dict]:
|
||||||
|
"""Fetch all available versions for a programme ID."""
|
||||||
|
r = self.session.get(url=self.config["endpoints"]["playlist"].format(pid=pid))
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
playlist = r.json()
|
playlist = r.json()
|
||||||
|
|
||||||
versions = playlist.get("allAvailableVersions")
|
versions = playlist.get("allAvailableVersions")
|
||||||
if not versions:
|
if versions:
|
||||||
# If API returns no versions, try to fetch from site source code
|
return 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.get("versions", {}) if not x.get("kind") == "audio-described"}]
|
|
||||||
|
|
||||||
if self.vcodec == "H.265":
|
# Fallback to scraping webpage if API returns no versions
|
||||||
versions = [{"pid": playlist.get("defaultAvailableVersion", {}).get("pid")}]
|
self.log.info("No versions in playlist API, falling back to webpage scrape.")
|
||||||
|
r = self.session.get(self.config["base_url"].format(type="episode", pid=pid))
|
||||||
|
r.raise_for_status()
|
||||||
|
match = re.search(r"window\.__IPLAYER_REDUX_STATE__\s*=\s*(.*?);\s*</script>", r.text)
|
||||||
|
if match:
|
||||||
|
redux_data = json.loads(match.group(1))
|
||||||
|
# Filter out audio-described versions
|
||||||
|
return [
|
||||||
|
{"pid": v.get("id")}
|
||||||
|
for v in redux_data.get("versions", {}).values()
|
||||||
|
if v.get("kind") != "audio-described" and v.get("id")
|
||||||
|
]
|
||||||
|
|
||||||
if not versions:
|
return []
|
||||||
self.log.error(" - No available versions for this title was found")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
connections = [self.check_all_versions(version) for version in (x.get("pid") for x in versions)]
|
def _select_best_media(self, connections: list[list[dict]]) -> list[dict]:
|
||||||
quality = [connection.get("height") for i in connections for connection in i if connection.get("height")]
|
"""Selects the media group corresponding to the highest available video quality."""
|
||||||
max_quality = max((h for h in quality if h < "1080"), default=None)
|
heights = sorted(
|
||||||
|
{
|
||||||
media = next(
|
int(c["height"])
|
||||||
(i for i in connections if any(connection.get("height") == max_quality for connection in i)),
|
for media_list in connections
|
||||||
None,
|
for c in media_list
|
||||||
|
if c.get("height", "").isdigit()
|
||||||
|
},
|
||||||
|
reverse=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
if not media:
|
if not heights:
|
||||||
self.log.error(" - Selection unavailable. Title doesn't exist or your IP address is blocked")
|
self.log.warning("No video streams with height information were found.")
|
||||||
sys.exit(1)
|
# Fallback: return the first available media group if any exist.
|
||||||
|
return connections[0] if connections else None
|
||||||
|
|
||||||
|
highest_height = heights[0]
|
||||||
|
self.log.debug(f"Available resolutions (p): {heights}. Selecting highest: {highest_height}p.")
|
||||||
|
|
||||||
|
best_media_list = next(
|
||||||
|
(
|
||||||
|
media_list
|
||||||
|
for media_list in connections
|
||||||
|
if any(conn.get("height") == str(highest_height) for conn in media_list)
|
||||||
|
),
|
||||||
|
None, # Default to None if no matching group is found (should be impossible if heights is not empty)
|
||||||
|
)
|
||||||
|
|
||||||
|
return best_media_list
|
||||||
|
|
||||||
|
def _select_tracks(self, media: list[dict], lang: str):
|
||||||
|
for video_stream_info in (m for m in media if m.get("kind") == "video"):
|
||||||
|
connections = sorted(video_stream_info["connection"], key=lambda x: x.get("priority", 99))
|
||||||
|
|
||||||
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":
|
if self.vcodec == "H.265":
|
||||||
connection = connections[0]
|
connection = connections[0]
|
||||||
else:
|
else:
|
||||||
connection = next(
|
connection = next((c for c in connections if c["supplier"] == "mf_akamai" and c["transferFormat"] == "dash"), None)
|
||||||
x for x in connections if x["supplier"] == "mf_akamai" and x["transferFormat"] == "dash"
|
|
||||||
)
|
|
||||||
|
|
||||||
break
|
break
|
||||||
|
|
||||||
if not self.vcodec == "H.265":
|
if not self.vcodec == "H.265":
|
||||||
if connection["transferFormat"] == "dash":
|
if connection["transferFormat"] == "dash":
|
||||||
connection["href"] = "/".join(
|
connection["href"] = "/".join(
|
||||||
connection["href"].replace("dash", "hls").split("?")[0].split("/")[0:-1] + ["hls", "master.m3u8"]
|
connection["href"]
|
||||||
|
.replace("dash", "hls")
|
||||||
|
.split("?")[0]
|
||||||
|
.split("/")[0:-1]
|
||||||
|
+ ["hls", "master.m3u8"]
|
||||||
)
|
)
|
||||||
connection["transferFormat"] = "hls"
|
connection["transferFormat"] = "hls"
|
||||||
elif connection["transferFormat"] == "hls":
|
elif connection["transferFormat"] == "hls":
|
||||||
connection["href"] = "/".join(
|
connection["href"] = "/".join(
|
||||||
connection["href"].replace(".hlsv2.ism", "").split("?")[0].split("/")[0:-1] + ["hls", "master.m3u8"]
|
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":
|
if connection["transferFormat"] == "dash":
|
||||||
tracks = DASH.from_url(url=connection["href"], session=self.session).to_tracks(language=title.language)
|
tracks = DASH.from_url(url=connection["href"], session=self.session).to_tracks(language=lang)
|
||||||
elif connection["transferFormat"] == "hls":
|
elif connection["transferFormat"] == "hls":
|
||||||
tracks = HLS.from_url(url=connection["href"], session=self.session).to_tracks(language=title.language)
|
tracks = HLS.from_url(url=connection["href"], session=self.session).to_tracks(language=lang)
|
||||||
else:
|
else:
|
||||||
raise ValueError(f"Unsupported video media transfer format {connection['transferFormat']!r}")
|
raise ValueError(f"Unsupported transfer format: {connection['transferFormat']}")
|
||||||
|
|
||||||
for video in tracks.videos:
|
for video in tracks.videos:
|
||||||
# UHD DASH manifest has no range information, so we add it manually
|
# UHD DASH manifest has no range information, so we add it manually
|
||||||
@ -242,7 +296,7 @@ class iP(Service):
|
|||||||
id_=hashlib.md5(connection["href"].encode()).hexdigest()[0:6],
|
id_=hashlib.md5(connection["href"].encode()).hexdigest()[0:6],
|
||||||
url=connection["href"],
|
url=connection["href"],
|
||||||
codec=Subtitle.Codec.from_codecs("ttml"),
|
codec=Subtitle.Codec.from_codecs("ttml"),
|
||||||
language=title.language,
|
language=lang,
|
||||||
is_original_lang=True,
|
is_original_lang=True,
|
||||||
forced=False,
|
forced=False,
|
||||||
sdh=True,
|
sdh=True,
|
||||||
@ -252,116 +306,119 @@ class iP(Service):
|
|||||||
|
|
||||||
return tracks
|
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:
|
def get_data(self, pid: str, slice_id: str) -> dict:
|
||||||
|
"""Fetches programme metadata from the GraphQL-like endpoint."""
|
||||||
json_data = {
|
json_data = {
|
||||||
"id": "9fd1636abe711717c2baf00cebb668de",
|
"id": "9fd1636abe711717c2baf00cebb668de",
|
||||||
"variables": {
|
"variables": {"id": pid, "perPage": 200, "page": 1, "sliceId": slice_id},
|
||||||
"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 = self.session.post(self.config["endpoints"]["metadata"], json=json_data)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
|
return r.json().get("data", {}).get("programme")
|
||||||
return r.json()["data"]["programme"]
|
|
||||||
|
|
||||||
def check_all_versions(self, vpid: str) -> list:
|
def check_all_versions(self, vpid: str) -> list:
|
||||||
media = None
|
"""Checks media availability for a given version PID, trying multiple mediators."""
|
||||||
|
session = self.session
|
||||||
|
cert_path = None
|
||||||
|
params = {}
|
||||||
|
|
||||||
if self.vcodec == "H.265":
|
if self.vcodec == "H.265":
|
||||||
if not self.config.get("cert"):
|
if not self.config.get("certificate"):
|
||||||
self.log.error(" - H.265 tracks cannot be requested without an SSL certificate")
|
raise CertificateMissingError("TLS certificate not configured.")
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
session = self.session
|
|
||||||
session.mount("https://", SSLCiphers())
|
session.mount("https://", SSLCiphers())
|
||||||
session.mount("http://", SSLCiphers())
|
endpoint_template = self.config["endpoints"]["secure"]
|
||||||
|
mediators = ["securegate.iplayer.bbc.co.uk", "ipsecure.stage.bbc.co.uk"]
|
||||||
mediaset = "iptv-uhd"
|
mediaset = "iptv-uhd"
|
||||||
|
|
||||||
for mediator in ["securegate.iplayer.bbc.co.uk", "ipsecure.stage.bbc.co.uk"]:
|
cert_binary = base64.b64decode(self.config["certificate"])
|
||||||
availability = session.get(
|
with tempfile.NamedTemporaryFile(mode="w+b", delete=False, suffix=".pem") as cert_file:
|
||||||
self.config["endpoints"]["secure"].format(mediator, vpid, mediaset),
|
cert_file.write(cert_binary)
|
||||||
cert=self.config["cert"],
|
cert_path = cert_file.name
|
||||||
).json()
|
|
||||||
if availability.get("media"):
|
|
||||||
media = availability["media"]
|
|
||||||
break
|
|
||||||
|
|
||||||
if availability.get("result"):
|
|
||||||
self.log.error(f"Error: {availability['result']}")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
|
params["cert"] = cert_path
|
||||||
else:
|
else:
|
||||||
|
endpoint_template = self.config["endpoints"]["open"]
|
||||||
|
mediators = ["open.live.bbc.co.uk", "open.stage.bbc.co.uk"]
|
||||||
mediaset = "iptv-all"
|
mediaset = "iptv-all"
|
||||||
|
|
||||||
for mediator in ["open.live.bbc.co.uk", "open.stage.bbc.co.uk"]:
|
for mediator in mediators:
|
||||||
availability = self.session.get(
|
if self.vcodec == "H.265":
|
||||||
self.config["endpoints"]["open"].format(mediator, mediaset, vpid),
|
url = endpoint_template.format(mediator, vpid, mediaset)
|
||||||
).json()
|
else:
|
||||||
|
url = endpoint_template.format(mediator, mediaset, vpid)
|
||||||
|
|
||||||
|
try:
|
||||||
|
r = session.get(url, **params)
|
||||||
|
r.raise_for_status()
|
||||||
|
availability = r.json()
|
||||||
|
|
||||||
if availability.get("media"):
|
if availability.get("media"):
|
||||||
media = availability["media"]
|
return availability["media"]
|
||||||
break
|
if availability.get("result"):
|
||||||
|
self.log.warning(
|
||||||
|
f"Mediator '{mediator}' reported an error: {availability['result']}"
|
||||||
|
)
|
||||||
|
|
||||||
if availability.get("result"):
|
except Exception as e:
|
||||||
self.log.error(f"Error: {availability['result']}")
|
self.log.debug(f"Failed to check mediator '{mediator}': {e}")
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
return media
|
finally:
|
||||||
|
if cert_path is not None:
|
||||||
|
Path(cert_path).unlink(missing_ok=True)
|
||||||
|
|
||||||
def fetch_episode(self, pid: str) -> Series:
|
return None
|
||||||
|
|
||||||
|
def fetch_episode(self, pid: str) -> Episode:
|
||||||
|
"""Fetches and parses data for a single episode."""
|
||||||
r = self.session.get(self.config["endpoints"]["episodes"].format(pid=pid))
|
r = self.session.get(self.config["endpoints"]["episodes"].format(pid=pid))
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
|
data = r.json()
|
||||||
|
|
||||||
data = json.loads(r.content)
|
if not data.get("episodes"):
|
||||||
episode = data["episodes"][0]
|
return None
|
||||||
subtitle = episode.get("subtitle")
|
|
||||||
year = episode.get("release_date_time", "").split("-")[0]
|
|
||||||
numeric_position = episode.get("numeric_tleo_position")
|
|
||||||
|
|
||||||
if subtitle is not None:
|
episode_data = data["episodes"][0]
|
||||||
series = re.finditer(r"Series (\d+):|Season (\d+):|(\d{4}/\d{2}): Episode \d+", subtitle or "")
|
subtitle = episode_data.get("subtitle", "")
|
||||||
season_num = int(next((m.group(1) or m.group(2) or m.group(3).replace("/", "") for m in series), 0))
|
year = (episode_data.get("release_date_time", "") or "").split("-")[0]
|
||||||
if season_num == 0 and not data.get("slices"):
|
|
||||||
season_num = 1
|
series_match = next(re.finditer(r"Series (\d+).*?:|Season (\d+).*?:|(\d{4}/\d{2}): Episode \d+", subtitle), None)
|
||||||
number_match = re.finditer(r"(\d+)\.|Episode (\d+)", subtitle)
|
season_num = 0
|
||||||
number = int(next((m.group(1) or m.group(2) for m in number_match), numeric_position or 0))
|
if series_match:
|
||||||
name_match = re.search(r"\d+\. (.+)", subtitle)
|
season_str = next(g for g in series_match.groups() if g is not None)
|
||||||
name = (
|
season_num = int(season_str.replace("/", ""))
|
||||||
name_match.group(1)
|
elif not data.get("slices"): # Fallback for single-season shows
|
||||||
if name_match
|
season_num = 1
|
||||||
else subtitle
|
|
||||||
if not re.search(r"Series (\d+): Episode (\d+)", subtitle)
|
num_match = next(re.finditer(r"(\d+)\.|Episode (\d+)", subtitle), None)
|
||||||
else ""
|
number = 0
|
||||||
)
|
if num_match:
|
||||||
|
number = int(next(g for g in num_match.groups() if g is not None))
|
||||||
|
else:
|
||||||
|
number = episode_data.get("numeric_tleo_position", 0)
|
||||||
|
|
||||||
|
name_match = re.search(r"\d+\. (.+)", subtitle)
|
||||||
|
name = ""
|
||||||
|
if name_match:
|
||||||
|
name = name_match.group(1)
|
||||||
|
elif not re.search(r"Series \d+: Episode \d+", subtitle):
|
||||||
|
name = subtitle
|
||||||
|
|
||||||
return Episode(
|
return Episode(
|
||||||
id_=episode.get("id"),
|
id_=episode_data.get("id"),
|
||||||
service=self.__class__,
|
service=self.__class__,
|
||||||
title=episode.get("title"),
|
title=episode_data.get("title"),
|
||||||
season=season_num if subtitle else 0,
|
season=season_num,
|
||||||
number=number if subtitle else 0,
|
number=number,
|
||||||
name=name if subtitle else "",
|
name=name,
|
||||||
language="en",
|
language="en",
|
||||||
year=year,
|
year=year,
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_episodes(self, episodes: list) -> list:
|
def get_episodes(self, episode_ids: list) -> list[Episode]:
|
||||||
|
"""Fetches multiple episodes concurrently."""
|
||||||
with ThreadPoolExecutor(max_workers=10) as executor:
|
with ThreadPoolExecutor(max_workers=10) as executor:
|
||||||
tasks = list(executor.map(self.fetch_episode, episodes))
|
tasks = executor.map(self.fetch_episode, episode_ids)
|
||||||
return [task for task in tasks if task is not None]
|
return [task for task in tasks if task is not None]
|
||||||
|
|
||||||
def find(self, pattern, string, group=None):
|
def find(self, pattern, string, group=None):
|
||||||
@ -371,3 +428,23 @@ class iP(Service):
|
|||||||
return m.group(group)
|
return m.group(group)
|
||||||
else:
|
else:
|
||||||
return next(iter(re.findall(pattern, string)), None)
|
return next(iter(re.findall(pattern, string)), None)
|
||||||
|
|
||||||
|
|
||||||
|
class iPlayerError(Exception):
|
||||||
|
"""Base exception for this service."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class CertificateMissingError(iPlayerError):
|
||||||
|
"""Raised when an TLS certificate is required but not provided."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class NoStreamsAvailableError(iPlayerError):
|
||||||
|
"""Raised when no playable streams are found for a title."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class MetadataError(iPlayerError):
|
||||||
|
"""Raised when metadata for a title cannot be found."""
|
||||||
|
pass
|
@ -9,3 +9,48 @@ endpoints:
|
|||||||
open: https://{}/mediaselector/6/select/version/2.0/mediaset/{}/vpid/{}/
|
open: https://{}/mediaselector/6/select/version/2.0/mediaset/{}/vpid/{}/
|
||||||
secure: https://{}/mediaselector/6/select/version/2.0/vpid/{}/format/json/mediaset/{}/proto/https
|
secure: https://{}/mediaselector/6/select/version/2.0/vpid/{}/format/json/mediaset/{}/proto/https
|
||||||
search: https://ibl.api.bbc.co.uk/ibl/v1/new-search
|
search: https://ibl.api.bbc.co.uk/ibl/v1/new-search
|
||||||
|
|
||||||
|
certificate: |
|
||||||
|
LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tDQpNSUlFT3pDQ0F5T2dBd0lCQWdJQkFUQU5CZ2txaGtpRzl3MEJBUVVGQURDQm96RU
|
||||||
|
xNQWtHQTFVRUJoTUNWVk14DQpFekFSQmdOVkJBZ1RDa05oYkdsbWIzSnVhV0V4RWpBUUJnTlZCQWNUQ1VOMWNHVnlkR2x1YnpFZU1C
|
||||||
|
d0dBMVVFDQpDeE1WVUhKdlpDQlNiMjkwSUVObGNuUnBabWxqWVhSbE1Sa3dGd1lEVlFRTEV4QkVhV2RwZEdGc0lGQnliMlIxDQpZM1
|
||||||
|
J6TVE4d0RRWURWUVFLRXdaQmJXRjZiMjR4SHpBZEJnTlZCQU1URmtGdFlYcHZiaUJHYVhKbFZGWWdVbTl2DQpkRU5CTURFd0hoY05N
|
||||||
|
VFF4TURFMU1EQTFPREkyV2hjTk16UXhNREV3TURBMU9ESTJXakNCbVRFTE1Ba0dBMVVFDQpCaE1DVlZNeEV6QVJCZ05WQkFnVENrTm
|
||||||
|
hiR2xtYjNKdWFXRXhFakFRQmdOVkJBY1RDVU4xY0dWeWRHbHViekVkDQpNQnNHQTFVRUN4TVVSR1YySUZKdmIzUWdRMlZ5ZEdsbWFX
|
||||||
|
TmhkR1V4R1RBWEJnTlZCQXNURUVScFoybDBZV3dnDQpVSEp2WkhWamRITXhEekFOQmdOVkJBb1RCa0Z0WVhwdmJqRVdNQlFHQTFVRU
|
||||||
|
F4TU5SbWx5WlZSV1VISnZaREF3DQpNVENDQVNBd0RRWUpLb1pJaHZjTkFRRUJCUUFEZ2dFTkFEQ0NBUWdDZ2dFQkFNRFZTNUwwVUR4
|
||||||
|
WnMwNkpGMld2DQpuZE1KajdIVGRlSlg5b0ltWWg3aytNY0VENXZ5OTA2M0p5c3FkS0tsbzVJZERvY2tuczg0VEhWNlNCVkFBaTBEDQ
|
||||||
|
p6cEI4dHRJNUFBM1l3djFZUDJiOThpQ3F2OWhQalZndE9nNHFvMXZkK0oxdFdISUh5ZkV6cWlPRXVXNTlVd2xoDQpVTmFvY3JtZGNx
|
||||||
|
bGcyWmIyZ1VybTZ2dlZqUThZcjQzY29MNnBBMk5ESXNyT0Z4c0ZZaXdaVk12cDZqMlk4dnFrDQpFOHJ2Tm04c3JkY0FhZjRXdHBuYW
|
||||||
|
gyZ3RBY3IrdTVYNExZdmEwTzZrNGhENEdnNHZQQ2xQZ0JXbDZFSHRBdnFDDQpGWm9KbDhMNTN2VVY1QWhMQjdKQk0wUTFXVERINWs4
|
||||||
|
NWNYT2tFd042NDhuZ09hZUtPMGxqYndZVG52NHhDV2NlDQo2RXNDQVFPamdZTXdnWUF3SHdZRFZSMGpCQmd3Rm9BVVo2RFJJSlNLK2
|
||||||
|
hmWCtHVnBycWlubGMraTVmZ3dIUVlEDQpWUjBPQkJZRUZOeUNPZkhja3Vpclp2QXF6TzBXbjZLTmtlR1BNQWtHQTFVZEV3UUNNQUF3
|
||||||
|
RXdZRFZSMGxCQXd3DQpDZ1lJS3dZQkJRVUhBd0l3RVFZSllJWklBWWI0UWdFQkJBUURBZ2VBTUFzR0ExVWREd1FFQXdJSGdEQU5CZ2
|
||||||
|
txDQpoa2lHOXcwQkFRVUZBQU9DQVFFQXZXUHd4b1VhV3IwV0tXRXhHdHpQOElGVUUrZis5SUZjSzNoWXl2QmxLOUxODQo3Ym9WZHhx
|
||||||
|
dWJGeEgzMFNmOC90VnNYMUpBOUM3bnMzZ09jV2Z0dTEzeUtzK0RnZGhqdG5GVkgraW4zNkVpZEZBDQpRRzM1UE1PU0ltNGNaVXkwME
|
||||||
|
4xRXRwVGpGY2VBbmF1ZjVJTTZNZmRBWlQ0RXNsL09OUHp5VGJYdHRCVlpBQmsxDQpXV2VHMEcwNDdUVlV6M2Ira0dOVTNzZEs5Ri9o
|
||||||
|
NmRiS3c0azdlZWJMZi9KNjZKSnlkQUhybFhJdVd6R2tDbjFqDQozNWdHRHlQajd5MDZWNXV6MlUzYjlMZTdZWENnNkJCanBRN0wrRW
|
||||||
|
d3OVVsSmpoN1pRMXU2R2RCNUEwcGFWM0VQDQpQTk1KN2J6Rkl1cHozdklPdk5nUVV4ZWs1SUVIczZKeXdjNXByck5MS3c9PQ0KLS0t
|
||||||
|
LS1FTkQgQ0VSVElGSUNBVEUtLS0tLQ0KLS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0tDQpNSUlFdlFJQkFEQU5CZ2txaGtpRzl3ME
|
||||||
|
JBUUVGQUFTQ0JLY3dnZ1NqQWdFQUFvSUJBUURBMVV1UzlGQThXYk5PDQppUmRscjUzVENZK3gwM1hpVi9hQ0ptSWU1UGpIQkErYjh2
|
||||||
|
ZE90eWNyS25TaXBhT1NIUTZISko3UE9FeDFla2dWDQpRQUl0QTg2UWZMYlNPUUFOMk1MOVdEOW0vZklncXIvWVQ0MVlMVG9PS3FOYj
|
||||||
|
NmaWRiVmh5QjhueE02b2poTGx1DQpmVk1KWVZEV3FISzVuWEtwWU5tVzlvRks1dXI3MVkwUEdLK04zS0MrcVFOalF5TEt6aGNiQldJ
|
||||||
|
c0dWVEw2ZW85DQptUEw2cEJQSzd6WnZMSzNYQUduK0ZyYVoyb2RvTFFISy9ydVYrQzJMMnREdXBPSVErQm9PTHp3cFQ0QVZwZWhCDQ
|
||||||
|
o3UUw2Z2hXYUNaZkMrZDcxRmVRSVN3ZXlRVE5FTlZrd3grWlBPWEZ6cEJNRGV1UEo0RG1uaWp0SlkyOEdFNTcrDQpNUWxuSHVoTEFn
|
||||||
|
RURBb0lCQVFDQWpqSmgrRFY5a1NJMFcyVHVkUlBpQmwvTDRrNlc1VThCYnV3VW1LWGFBclVTDQpvZm8wZWhvY3h2aHNibTBNRTE4RX
|
||||||
|
d4U0tKWWhPVVlWamdBRnpWOThLL2M4MjBLcXo1ZGRUa0NwRXFVd1Z4eXFRDQpOUWpsYzN3SmNjSTlQcVcrU09XaFdvYWd6UndYcmRE
|
||||||
|
MFU0eXc2NHM1eGFIUkU2SEdRSkVQVHdEY21mSDlOK0JXDQovdVU4YVc1QWZOcHhqRzduSGF0cmhJQjU1cDZuNHNFNUVoTjBnSk9WMD
|
||||||
|
lmMEdOb1pQUVhiT1VVcEJWOU1jQ2FsDQpsK1VTalpBRmRIbUlqWFBwR1FEelJJWTViY1hVQzBZYlRwaytRSmhrZ1RjSW1LRFJmd0FC
|
||||||
|
YXRIdnlMeDlpaVY1DQp0ZWZoV1hhaDE4STdkbUF3TmRTN0U4QlpoL3d5MlIwNXQ0RHppYjlyQW9HQkFPU25yZXAybk1VRVAyNXdSQW
|
||||||
|
RBDQozWDUxenYwOFNLWkh6b0VuNExRS1krLzg5VFRGOHZWS2wwQjZLWWlaYW14aWJqU1RtaDRCWHI4ZndRaytiazFCDQpReEZ3ZHVG
|
||||||
|
eTd1MU43d0hSNU45WEFpNEtuamgxQStHcW9SYjg4bk43b1htekM3cTZzdFZRUk9peDJlRVFJWTVvDQpiREZUellaRnloNGlMdkU0bj
|
||||||
|
V1WnVHL1JBb0dCQU5mazdHMDhvYlpacmsxSXJIVXZSQmVENzZRNDlzQ0lSMGRBDQpIU0hCZjBadFBEMjdGSEZtamFDN0YwWkM2QXdU
|
||||||
|
RnBNL0FNWDR4UlpqNnhGalltYnlENGN3MFpGZ08rb0pwZjFIDQpFajNHSHdMNHFZekJFUXdRTmswSk9GbE84cDdVMm1ZL2hEVXM3bG
|
||||||
|
JQQm82YUo4VVpJMGs3SHhSOVRWYVhud0h1DQovaXhnRjlsYkFvR0JBSmh2eVViNXZkaXRmNTcxZ3ErQWs2bWozMU45aGNRdjN3REZR
|
||||||
|
SGdHN1Vxb28zaUQ5MDR4DQp1aXI4RzdCbVJ2THNTWGhpWnI2cmxIOXFnTERVU1lqV0xMWksrZXVoOUo0ejlLdmhReitQVnNsY2FYcj
|
||||||
|
RyVUVjDQphMlNvb2FKU2E2WjNYU2NuSWVPSzJKc2hPK3RnRmw3d1NDRGlpUVF1aHI3QmRLRFFhbWU3MEVxTEFvR0JBSS90DQo4dk45
|
||||||
|
d1NRN3lZamJIYU4wMkErdFNtMTdUeXNGaE5vcXZoYUEvNFJJMHRQU0RhRHZDUlhTRDRRc21ySzNaR0lxDQpBSVA3TGc3dFIyRHM3RV
|
||||||
|
NoWDY5MTRRdVZmVWF4R1ZPRXR0UFphZ0g3RzdNcllMSzFlWWl3MER1Sjl4U041dTdWDQpBczRkOURuZldiUm14UzRRd2pEU0ZMaFRp
|
||||||
|
T1JsRkt2MHFYTHF1cERuQW9HQWVFa3J4SjhJaXdhVEhnWXltM21TDQprU2h5anNWK01tVkJsVHNRK0ZabjFTM3k0YVdxbERhNUtMZF
|
||||||
|
QvWDEwQXg4NHNQTmVtQVFVMGV4YTN0OHM5bHdIDQorT3NEaktLb3hqQ1Q3S2wzckdQeUFISnJmVlZ5U2VFZVgrOERLZFZKcjByU1Bk
|
||||||
|
Qkk4Y2tFQ3kzQXpsVmphK3d3DQpST0N0emMxVHVyeG5OQTVxV0QzbjNmND0NCi0tLS0tRU5EIFBSSVZBVEUgS0VZLS0tLS0NCg==
|
||||||
|
Loading…
x
Reference in New Issue
Block a user