fix(iP): Fix failing audio track for Devine v.3.3.3, fix HLG range

- MIssing data for the added audio track in Devine v3.3.3 has been fixed and will now download properly.
- The range for UHD tracks were previously incorrectly listed as SDR but is now listed as HLG. You'll need to use `--range HLG` to request UHD tracks.
- Single episodes are now fetched from API rather than site source code.
This commit is contained in:
stabbedbybrick 2024-05-09 21:10:53 +02:00
parent 7f37dc9571
commit c1d517f18c
2 changed files with 50 additions and 42 deletions

View File

@ -15,7 +15,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, Track, Tracks, Video from devine.core.tracks import Audio, Chapter, 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
@ -45,7 +45,7 @@ class iP(Service):
iP: iP:
cert: path/to/cert cert: path/to/cert
\b \b
- Use -v H.265 to request 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:
https://www.bbc.co.uk/iplayer/help/questions/programme-availability/uhd-content https://www.bbc.co.uk/iplayer/help/questions/programme-availability/uhd-content
""" """
@ -64,15 +64,14 @@ class iP(Service):
def __init__(self, ctx: Context, title: str): def __init__(self, ctx: Context, title: str):
self.title = title self.title = title
self.vcodec = ctx.parent.params.get("vcodec") self.vcodec = ctx.parent.params.get("vcodec")
self.range = ctx.parent.params.get("range_")
super().__init__(ctx) super().__init__(ctx)
if self.vcodec == "H.265" and not self.config.get("cert"): if self.range[0].name == "HLG" and not self.config.get("cert"):
self.log.error("H.265 cannot be selected without a certificate") self.log.error("UHD tracks cannot be selected without an SSL certificate")
sys.exit(1) sys.exit(1)
quality = ctx.parent.params.get("quality") elif self.range[0].name == "HLG" and self.config.get("cert"):
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" self.vcodec = "H.265"
def search(self) -> Generator[SearchResult, None, None]: def search(self) -> Generator[SearchResult, None, None]:
@ -97,15 +96,13 @@ class iP(Service):
def get_titles(self) -> Union[Movies, Series]: def get_titles(self) -> Union[Movies, Series]:
kind, pid = (re.match(self.TITLE_RE, self.title).group(i) for i in ("kind", "id")) kind, pid = (re.match(self.TITLE_RE, self.title).group(i) for i in ("kind", "id"))
if not pid: if not pid:
self.log.error("Unable to parse title ID - is the URL or id correct?") raise ValueError("Unable to parse title ID - is the URL or id correct?")
sys.exit(1)
data = self.get_data(pid, slice_id=None) data = self.get_data(pid, slice_id=None)
if data is None and kind == "episode": if data is None and kind == "episode":
return self.get_single_episode(self.title) return self.get_single_episode(pid)
elif data is None: elif data is None:
self.log.error("Metadata was not found - if %s is an episode, use full URL as input", pid) raise ValueError(f"Metadata was not found - if {pid} is an episode, use full URL as input")
sys.exit(1)
if "Film" in data["labels"]["category"]: if "Film" in data["labels"]["category"]:
return Movies( return Movies(
@ -122,7 +119,9 @@ class iP(Service):
) )
else: else:
seasons = [self.get_data(pid, x["id"]) for x in data["slices"] or [{"id": None}]] 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"]] episodes = [
self.create_episode(episode, data) for season in seasons for episode in season["entities"]["results"]
]
return Series(episodes) return Series(episodes)
def get_tracks(self, title: Union[Movie, Episode]) -> Tracks: def get_tracks(self, title: Union[Movie, Episode]) -> Tracks:
@ -133,27 +132,32 @@ class iP(Service):
versions = r.json().get("allAvailableVersions") versions = r.json().get("allAvailableVersions")
if not versions: if not versions:
# If API returns no versions, try to fetch from site source code
r = self.session.get(self.config["base_url"].format(type="episode", pid=title.id)) 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) redux = re.search("window.__IPLAYER_REDUX_STATE__ = (.*?);</script>", r.text).group(1)
data = json.loads(redux) data = json.loads(redux)
versions = [ versions = [{"pid": x.get("id") for x in data["versions"] if not x.get("kind") == "audio-described"}]
{"pid": x.get("id") for x in data["versions"] if not x.get("kind") == "audio-described"}
]
quality = [ quality = [
connection.get("height") connection.get("height")
for i in ( for i in (self.check_all_versions(version) for version in (x.get("pid") for x in versions))
self.check_all_versions(version)
for version in (x.get("pid") for x in versions)
)
for connection in i for connection in i
if connection.get("height") if connection.get("height")
] ]
max_quality = max((h for h in quality if h < "1080"), default=None) max_quality = max((h for h in quality if h < "1080"), default=None)
media = next((i for i in (self.check_all_versions(version) media = next(
for version in (x.get("pid") for x in versions)) (
if any(connection.get("height") == max_quality for connection in i)), None) 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,
)
if not media:
self.log.error("No media found. If you're behind a VPN/proxy, you might be blocked")
sys.exit(1)
connection = {} connection = {}
for video in [x for x in media if x["kind"] == "video"]: for video in [x for x in media if x["kind"] == "video"]:
@ -189,7 +193,9 @@ class iP(Service):
raise ValueError(f"Unsupported video media transfer format {connection['transferFormat']!r}") raise ValueError(f"Unsupported video media transfer format {connection['transferFormat']!r}")
for video in tracks.videos: for video in tracks.videos:
# TODO: add HLG to UHD tracks # UHD DASH manifest has no range information, so we add it manually
if video.codec == Video.Codec.HEVC:
video.range = Video.Range.HLG
if any(re.search(r"-audio_\w+=\d+", x) for x in as_list(video.url)): if any(re.search(r"-audio_\w+=\d+", x) for x in as_list(video.url)):
# create audio stream from the video stream # create audio stream from the video stream
@ -198,12 +204,14 @@ class iP(Service):
# use audio_url not video url, as to ignore video bitrate in ID # use audio_url not video url, as to ignore video bitrate in ID
id_=hashlib.md5(audio_url.encode()).hexdigest()[0:7], id_=hashlib.md5(audio_url.encode()).hexdigest()[0:7],
url=audio_url, url=audio_url,
codec=Audio.Codec.from_codecs("mp4a"), codec=Audio.Codec.from_codecs(video.data["hls"]["playlist"].stream_info.codecs),
language=[v.language for v in video.data["hls"]["playlist"].media][0], language=video.data["hls"]["playlist"].media[0].language,
bitrate=int(self.find(r"-audio_\w+=(\d+)", as_list(video.url)[0]) or 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], channels=video.data["hls"]["playlist"].media[0].channels,
descriptive=False, # Not available descriptive=False, # Not available
descriptor=Track.Descriptor.HLS, descriptor=Audio.Descriptor.HLS,
drm=video.drm,
data=video.data,
) )
if not tracks.exists(by_id=audio.id): if not tracks.exists(by_id=audio.id):
# some video streams use the same audio, so natural dupes exist # some video streams use the same audio, so natural dupes exist
@ -313,13 +321,12 @@ class iP(Service):
data=episode, data=episode,
) )
def get_single_episode(self, url: str) -> Series: def get_single_episode(self, pid: str) -> Series:
r = self.session.get(url) r = self.session.get(self.config["endpoints"]["episodes"].format(pid=pid))
r.raise_for_status() r.raise_for_status()
redux = re.search("window.__IPLAYER_REDUX_STATE__ = (.*?);</script>", r.text).group(1) data = json.loads(r.content)
data = json.loads(redux) subtitle = data["episodes"][0].get("subtitle")
subtitle = data["episode"].get("subtitle")
if subtitle is not None: if subtitle is not None:
season_match = re.search(r"Series (\d+):", subtitle) season_match = re.search(r"Series (\d+):", subtitle)
@ -338,9 +345,9 @@ class iP(Service):
return Series( return Series(
[ [
Episode( Episode(
id_=data["episode"]["id"], id_=data["episodes"][0]["id"],
service=self.__class__, service=self.__class__,
title=data["episode"]["title"], title=data["episodes"][0]["title"],
season=season if subtitle else 0, season=season if subtitle else 0,
number=number if subtitle else 0, number=number if subtitle else 0,
name=name if subtitle else "", name=name if subtitle else "",

View File

@ -1,10 +1,11 @@
base_url: https://www.bbc.co.uk/iplayer/{type}/{pid} base_url: https://www.bbc.co.uk/iplayer/{type}/{pid}
user_agent: 'smarttv_AFTMM_Build_0003255372676_Chromium_41.0.2250.2' user_agent: smarttv_AFTMM_Build_0003255372676_Chromium_41.0.2250.2
api_key: 'D2FgtcTxGqqIgLsfBWTJdrQh2tVdeaAp' api_key: D2FgtcTxGqqIgLsfBWTJdrQh2tVdeaAp
endpoints: endpoints:
metadata: 'https://graph.ibl.api.bbc.co.uk/' episodes: https://ibl.api.bbci.co.uk/ibl/v1/episodes/{pid}?rights=mobile&availability=available
playlist: 'https://www.bbc.co.uk/programmes/{pid}/playlist.json' metadata: https://graph.ibl.api.bbc.co.uk/
manifest: "https://open.live.bbc.co.uk/mediaselector/6/select/version/2.0/mediaset/{mediaset}/vpid/{vpid}/" playlist: https://www.bbc.co.uk/programmes/{pid}/playlist.json
manifest_: 'https://securegate.iplayer.bbc.co.uk/mediaselector/6/select/version/2.0/vpid/{vpid}/format/json/mediaset/{mediaset}/proto/https' manifest: https://open.live.bbc.co.uk/mediaselector/6/select/version/2.0/mediaset/{mediaset}/vpid/{vpid}/
search: "https://search.api.bbci.co.uk/formula/iplayer-ibl-root" 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