From c1d517f18c7b1b4bf78b48b467f126e889076822 Mon Sep 17 00:00:00 2001 From: stabbedbybrick <125766685+stabbedbybrick@users.noreply.github.com> Date: Thu, 9 May 2024 21:10:53 +0200 Subject: [PATCH] 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. --- services/iP/__init__.py | 77 ++++++++++++++++++++++------------------- services/iP/config.yaml | 15 ++++---- 2 files changed, 50 insertions(+), 42 deletions(-) diff --git a/services/iP/__init__.py b/services/iP/__init__.py index ebca286..67ec90b 100644 --- a/services/iP/__init__.py +++ b/services/iP/__init__.py @@ -15,7 +15,7 @@ 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.tracks import Audio, Chapter, Subtitle, Tracks, Video from devine.core.utils.collections import as_list from devine.core.utils.sslciphers import SSLCiphers @@ -45,7 +45,7 @@ class iP(Service): iP: cert: path/to/cert \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: 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): self.title = title self.vcodec = ctx.parent.params.get("vcodec") + self.range = ctx.parent.params.get("range_") 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") + if self.range[0].name == "HLG" and not self.config.get("cert"): + self.log.error("UHD tracks cannot be selected without an SSL 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") + elif self.range[0].name == "HLG" and self.config.get("cert"): self.vcodec = "H.265" def search(self) -> Generator[SearchResult, None, None]: @@ -97,15 +96,13 @@ class iP(Service): 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) + raise ValueError("Unable to parse title ID - is the URL or id correct?") data = self.get_data(pid, slice_id=None) if data is None and kind == "episode": - return self.get_single_episode(self.title) + return self.get_single_episode(pid) 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) + raise ValueError(f"Metadata was not found - if {pid} is an episode, use full URL as input") if "Film" in data["labels"]["category"]: return Movies( @@ -122,7 +119,9 @@ class iP(Service): ) 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"]] + 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: @@ -133,27 +132,32 @@ class iP(Service): versions = r.json().get("allAvailableVersions") 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)) redux = re.search("window.__IPLAYER_REDUX_STATE__ = (.*?);", 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"} - ] + 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 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) + 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, + ) + + if not media: + self.log.error("No media found. If you're behind a VPN/proxy, you might be blocked") + sys.exit(1) connection = {} 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}") 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)): # 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 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], + codec=Audio.Codec.from_codecs(video.data["hls"]["playlist"].stream_info.codecs), + language=video.data["hls"]["playlist"].media[0].language, 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 - descriptor=Track.Descriptor.HLS, + descriptor=Audio.Descriptor.HLS, + drm=video.drm, + data=video.data, ) if not tracks.exists(by_id=audio.id): # some video streams use the same audio, so natural dupes exist @@ -313,13 +321,12 @@ class iP(Service): data=episode, ) - def get_single_episode(self, url: str) -> Series: - r = self.session.get(url) + def get_single_episode(self, pid: str) -> Series: + r = self.session.get(self.config["endpoints"]["episodes"].format(pid=pid)) r.raise_for_status() - redux = re.search("window.__IPLAYER_REDUX_STATE__ = (.*?);", r.text).group(1) - data = json.loads(redux) - subtitle = data["episode"].get("subtitle") + data = json.loads(r.content) + subtitle = data["episodes"][0].get("subtitle") if subtitle is not None: season_match = re.search(r"Series (\d+):", subtitle) @@ -338,9 +345,9 @@ class iP(Service): return Series( [ Episode( - id_=data["episode"]["id"], + id_=data["episodes"][0]["id"], service=self.__class__, - title=data["episode"]["title"], + title=data["episodes"][0]["title"], season=season if subtitle else 0, number=number if subtitle else 0, name=name if subtitle else "", diff --git a/services/iP/config.yaml b/services/iP/config.yaml index 2019a1c..66ae782 100644 --- a/services/iP/config.yaml +++ b/services/iP/config.yaml @@ -1,10 +1,11 @@ base_url: https://www.bbc.co.uk/iplayer/{type}/{pid} -user_agent: 'smarttv_AFTMM_Build_0003255372676_Chromium_41.0.2250.2' -api_key: 'D2FgtcTxGqqIgLsfBWTJdrQh2tVdeaAp' +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" + episodes: https://ibl.api.bbci.co.uk/ibl/v1/episodes/{pid}?rights=mobile&availability=available + 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