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…
Reference in New Issue
Block a user