from __future__ import annotations import hashlib import json import re import sys from collections.abc import Generator from http.cookiejar import MozillaCookieJar from typing import Any, Optional, Union import click from bs4 import BeautifulSoup from click import Context 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, Chapters, Subtitle, Tracks class ITV(Service): """ Service code for ITVx streaming service (https://www.itv.com/). \b Author: stabbedbybrick Authorization: Cookies (Optional for free content | Required for premium content) Robustness: L1: 1080p L3: 720p \b Tips: - Use complete title URL as input (pay attention to the URL format): SERIES: https://www.itv.com/watch/bay-of-fires/10a5270 EPISODE: https://www.itv.com/watch/bay-of-fires/10a5270/10a5270a0001 FILM: https://www.itv.com/watch/mad-max-beyond-thunderdome/2a7095 - Some shows aren't listed as series, only as "Latest episodes" Download by SERIES URL for those titles, not by EPISODE URL \b Examples: - SERIES: devine dl -w s01e01 itv https://www.itv.com/watch/bay-of-fires/10a5270 - EPISODE: devine dl itv https://www.itv.com/watch/bay-of-fires/10a5270/10a5270a0001 - FILM: devine dl itv https://www.itv.com/watch/mad-max-beyond-thunderdome/2a7095 \b Notes: ITV seem to detect and throttle multiple connections against the server. It's recommended to use requests as downloader, with few workers. """ GEOFENCE = ("gb",) ALIASES = ("itvx",) @staticmethod @click.command(name="ITV", short_help="https://www.itv.com/", help=__doc__) @click.argument("title", type=str) @click.pass_context def cli(ctx: Context, **kwargs: Any) -> ITV: return ITV(ctx, **kwargs) def __init__(self, ctx: Context, title: str): self.title = title super().__init__(ctx) self.profile = ctx.parent.params.get("profile") if not self.profile: self.profile = "default" self.session.headers.update(self.config["headers"]) def authenticate(self, cookies: Optional[MozillaCookieJar] = None, credential: Optional[Credential] = None) -> None: super().authenticate(cookies, credential) self.authorization = None if credential and not cookies: self.log.error(" - Error: This service requires cookies for authentication.") sys.exit(1) if cookies is not None: self.log.info(f"\n + Cookies for '{self.profile}' profile found, authenticating...") itv_session = next((cookie.value for cookie in cookies if cookie.name == "Itv.Session"), None) if not itv_session: self.log.error(" - Error: Session cookie not found. Cookies may be invalid.") sys.exit(1) itv_session = json.loads(itv_session) refresh_token = itv_session["tokens"]["content"].get("refresh_token") if not refresh_token: self.log.error(" - Error: Access tokens not found. Try refreshing your cookies.") sys.exit(1) cache = self.cache.get(f"tokens_{self.profile}") headers = { "Host": "auth.prd.user.itv.com", "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:126.0) Gecko/20100101 Firefox/126.0", "Accept": "application/vnd.user.auth.v2+json", "Accept-Language": "en-US,en;q=0.8", "Origin": "https://www.itv.com", "Connection": "keep-alive", "Referer": "https://www.itv.com/", } params = {"refresh": cache.data["refresh_token"]} if cache else {"refresh": refresh_token} r = self.session.get( self.config["endpoints"]["refresh"], headers=headers, params=params, ) if r.status_code != 200: raise ConnectionError(f"Failed to refresh tokens: {r.text}") tokens = r.json() cache.set(tokens) self.log.info(" + Tokens refreshed and placed in cache\n") self.authorization = tokens["access_token"] def search(self) -> Generator[SearchResult, None, None]: params = { "broadcaster": "itv", "featureSet": "clearkey,outband-webvtt,hls,aes,playready,widevine,fairplay,bbts,progressive,hd,rtmpe", "onlyFree": "false", "platform": "dotcom", "query": self.title, } r = self.session.get(self.config["endpoints"]["search"], params=params) r.raise_for_status() results = r.json()["results"] if isinstance(results, list): for result in results: special = result["data"].get("specialTitle") standard = result["data"].get("programmeTitle") film = result["data"].get("filmTitle") title = special if special else standard if standard else film tier = result["data"].get("tier") slug = self._sanitize(title) _id = result["data"]["legacyId"]["apiEncoded"] _id = "_".join(_id.split("_")[:2]).replace("_", "a") _id = re.sub(r"a000\d+", "", _id) yield SearchResult( id_=f"https://www.itv.com/watch/{slug}/{_id}", title=title, description=result["data"].get("synopsis"), label=result.get("entityType") + f" {tier}", url=f"https://www.itv.com/watch/{slug}/{_id}", ) def get_titles(self) -> Union[Movies, Series]: data = self.get_data(self.title) kind = next( (x.get("seriesType") for x in data.get("seriesList") if x.get("seriesType") in ["SERIES", "FILM"]), None ) # Some shows are not listed as "SERIES" or "FILM", only as "Latest episodes" if not kind and next( (x for x in data.get("seriesList") if x.get("seriesLabel").lower() == "latest episodes"), None ): titles = data["seriesList"][0]["titles"] return Series( [ Episode( id_=episode["episodeId"], service=self.__class__, title=data["programme"]["title"], season=episode.get("series") if isinstance(episode.get("series"), int) else 0, number=episode.get("episode") if isinstance(episode.get("episode"), int) else 0, name=episode["episodeTitle"], language="en", # TODO: language detection data=episode, ) for episode in titles ] ) if kind == "SERIES" and data.get("episode"): episode = data.get("episode") return Series( [ Episode( id_=episode["episodeId"], service=self.__class__, title=data["programme"]["title"], season=episode.get("series") if isinstance(episode.get("series"), int) else 0, number=episode.get("episode") if isinstance(episode.get("episode"), int) else 0, name=episode["episodeTitle"], language="en", # TODO: language detection data=episode, ) ] ) elif kind == "SERIES": return Series( [ Episode( id_=episode["episodeId"], service=self.__class__, title=data["programme"]["title"], season=episode.get("series") if isinstance(episode.get("series"), int) else 0, number=episode.get("episode") if isinstance(episode.get("episode"), int) else 0, name=episode["episodeTitle"], language="en", # TODO: language detection data=episode, ) for series in data["seriesList"] if "Latest episodes" not in series["seriesLabel"] for episode in series["titles"] ] ) elif kind == "FILM": return Movies( [ Movie( id_=movie["episodeId"], service=self.__class__, name=data["programme"]["title"], year=movie.get("productionYear"), language="en", # TODO: language detection data=movie, ) for movies in data["seriesList"] for movie in movies["titles"] ] ) def get_tracks(self, title: Union[Movie, Episode]) -> Tracks: playlist = title.data.get("playlistUrl") featureset = {k: ("mpeg-dash", "widevine", "outband-webvtt", "hd", "single-track") for k in ("min", "max")} payload = { "client": {"id": "browser"}, "variantAvailability": {"featureset": featureset, "platformTag": "dotcom"}, } if self.authorization: payload["user"] = {"token": self.authorization} r = self.session.post(playlist, json=payload) if r.status_code != 200: raise ConnectionError(r.text) data = r.json() video = data["Playlist"]["Video"] subtitles = video.get("Subtitles") self.manifest = video.get("Base") + video["MediaFiles"][0].get("Href") self.license = video["MediaFiles"][0].get("KeyServiceUrl") tracks = DASH.from_url(self.manifest, self.session).to_tracks(title.language) tracks.videos[0].data = data if subtitles is not None: for subtitle in subtitles: tracks.add( Subtitle( id_=hashlib.md5(subtitle.get("Href", "").encode()).hexdigest()[0:6], url=subtitle.get("Href", ""), codec=Subtitle.Codec.from_mime(subtitle.get("Href", "")[-3:]), language=title.language, forced=False, ) ) 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]) -> Chapters: track = title.tracks.videos[0] if not track.data["Playlist"].get("ContentBreaks"): return Chapters() breaks = track.data["Playlist"]["ContentBreaks"] timecodes = [".".join(x.get("TimeCode").rsplit(":", 1)) for x in breaks if x.get("TimeCode") != "00:00:00:000"] # End credits are sometimes listed before the last chapter, so we skip those for now return Chapters([Chapter(timecode) for timecode in timecodes]) def get_widevine_service_certificate(self, **_: Any) -> str: return None def get_widevine_license(self, challenge: bytes, **_: Any) -> bytes: r = self.session.post(url=self.license, data=challenge) if r.status_code != 200: raise ConnectionError(r.text) return r.content # Service specific functions def get_data(self, url: str) -> dict: # TODO: Find a proper endpoint for this r = self.session.get(url) if r.status_code != 200: raise ConnectionError(r.text) soup = BeautifulSoup(r.text, "html.parser") props = soup.select_one("#__NEXT_DATA__").text try: data = json.loads(props) except Exception as e: raise ValueError(f"Failed to parse JSON: {e}") return data["props"]["pageProps"] @staticmethod def _sanitize(title: str) -> str: title = title.lower() title = title.replace("&", "and") title = re.sub(r"[:;/()]", "", title) title = re.sub(r"[ ]", "-", title) title = re.sub(r"[\\*!?¿,'\"<>|$#`’]", "", title) title = re.sub(rf"[{'.'}]{{2,}}", ".", title) title = re.sub(rf"[{'_'}]{{2,}}", "_", title) title = re.sub(rf"[{'-'}]{{2,}}", "-", title) title = re.sub(rf"[{' '}]{{2,}}", " ", title) return title