feat(services): Add ITV service
This commit is contained in:
		
							parent
							
								
									79ad7516d1
								
							
						
					
					
						commit
						013c5f028c
					
				
							
								
								
									
										305
									
								
								services/ITV/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										305
									
								
								services/ITV/__init__.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,305 @@
 | 
			
		||||
from __future__ import annotations
 | 
			
		||||
 | 
			
		||||
import hashlib
 | 
			
		||||
import json
 | 
			
		||||
import re
 | 
			
		||||
import time
 | 
			
		||||
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: Credentials (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
 | 
			
		||||
 | 
			
		||||
    \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.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 is not None:
 | 
			
		||||
            cache = self.cache.get(f"tokens_{credential.sha1}")
 | 
			
		||||
 | 
			
		||||
            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/",
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            if cache:
 | 
			
		||||
                self.log.info(" + Using cached Tokens...")
 | 
			
		||||
                r = self.session.get(
 | 
			
		||||
                    self.config["endpoints"]["refresh"],
 | 
			
		||||
                    headers=headers,
 | 
			
		||||
                    params={"refresh": cache.data["refresh_token"]},
 | 
			
		||||
                )
 | 
			
		||||
                if r.status_code != 200:
 | 
			
		||||
                    raise ConnectionError(f"Failed to refresh tokens: {r.text}")
 | 
			
		||||
 | 
			
		||||
                tokens = r.json()
 | 
			
		||||
            else:
 | 
			
		||||
                r = self.session.post(
 | 
			
		||||
                    self.config["endpoints"]["login"],
 | 
			
		||||
                    headers=headers,
 | 
			
		||||
                    data=json.dumps(
 | 
			
		||||
                        {
 | 
			
		||||
                            "username": credential.username,
 | 
			
		||||
                            "password": credential.password,
 | 
			
		||||
                            "scope": "content",
 | 
			
		||||
                            "grant_type": "password",
 | 
			
		||||
                            "nonce": f"cerberus-auth-request-{int(time.time())}",
 | 
			
		||||
                        }
 | 
			
		||||
                    ),
 | 
			
		||||
                )
 | 
			
		||||
                if r.status_code != 200:
 | 
			
		||||
                    raise ConnectionError(f"Failed to authenticate: {r.text}")
 | 
			
		||||
 | 
			
		||||
                tokens = r.json()
 | 
			
		||||
                self.log.info(" + Acquired Tokens...")
 | 
			
		||||
 | 
			
		||||
            cache.set(tokens)
 | 
			
		||||
 | 
			
		||||
            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 = data["seriesList"][0]["seriesType"]
 | 
			
		||||
 | 
			
		||||
        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
 | 
			
		||||
							
								
								
									
										8
									
								
								services/ITV/config.yaml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								services/ITV/config.yaml
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,8 @@
 | 
			
		||||
headers:
 | 
			
		||||
  User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:126.0) Gecko/20100101 Firefox/126.0
 | 
			
		||||
  accept-language: en-US,en;q=0.8
 | 
			
		||||
 | 
			
		||||
endpoints:
 | 
			
		||||
  login: https://auth.prd.user.itv.com/v2/auth
 | 
			
		||||
  refresh: https://auth.prd.user.itv.com/token
 | 
			
		||||
  search: https://textsearch.prd.oasvc.itv.com/search
 | 
			
		||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user