This commit is contained in:
VineFeeder
2026-04-27 09:44:02 +01:00
parent 9e3d6dea08
commit 3624bb0c5d
3 changed files with 103 additions and 36 deletions
@@ -11,6 +11,7 @@ from typing import Any, Optional, Union
import click import click
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from click import Context from click import Context
from envied.core.cdm.detect import is_playready_cdm
from envied.core.credential import Credential from envied.core.credential import Credential
from envied.core.manifests.dash import DASH from envied.core.manifests.dash import DASH
from envied.core.search_result import SearchResult from envied.core.search_result import SearchResult
@@ -24,11 +25,16 @@ class ITV(Service):
Service code for ITVx streaming service (https://www.itv.com/). Service code for ITVx streaming service (https://www.itv.com/).
\b \b
Version: 1.0.3 Version: 1.0.4
Author: stabbedbybrick Author: stabbedbybrick
Authorization: Cookies (Optional for free content | Required for premium content) Authorization: Cookies (Optional for free content | Required for premium content)
Robustness: Robustness:
L3: 1080p Widevine:
L1: 1080p
L3: 720p
PlayReady:
SL3000: 1080p
SL2000: 720p
\b \b
Tips: Tips:
@@ -45,11 +51,6 @@ class ITV(Service):
- EPISODE: devine dl itv https://www.itv.com/watch/bay-of-fires/10a5270/10a5270a0001 - 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 - 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",) GEOFENCE = ("gb",)
@@ -70,6 +71,10 @@ class ITV(Service):
if not self.profile: if not self.profile:
self.profile = "default" self.profile = "default"
self.cdm = ctx.obj.cdm
self.drm_system = "playready" if is_playready_cdm(self.cdm) else "widevine"
self.security_level = f"SL{self.cdm.security_level}" if self.drm_system == "playready" else f"L{self.cdm.security_level}"
self.session.headers.update(self.config["headers"]) self.session.headers.update(self.config["headers"])
def authenticate(self, cookies: Optional[MozillaCookieJar] = None, credential: Optional[Credential] = None) -> None: def authenticate(self, cookies: Optional[MozillaCookieJar] = None, credential: Optional[Credential] = None) -> None:
@@ -261,15 +266,15 @@ class ITV(Service):
"player": "dash", "player": "dash",
"featureset": [ "featureset": [
"mpeg-dash", "mpeg-dash",
"widevine", self.drm_system,
"outband-webvtt", "outband-webvtt",
"hd", "hd",
"single-track", "single-track",
], ],
"platformTag": "ctv", "platformTag": "ctv",
"drm": { "drm": {
"system": "widevine", "system": self.drm_system,
"maxSupported": "L3", "maxSupported": self.security_level,
}, },
}, },
} }
@@ -328,6 +333,12 @@ class ITV(Service):
raise ConnectionError(r.text) raise ConnectionError(r.text)
return r.content return r.content
def get_playready_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 # Service specific functions
def get_data(self, url: str) -> dict: def get_data(self, url: str) -> dict:
@@ -4,18 +4,20 @@ import hashlib
import json import json
import re import re
from collections.abc import Generator from collections.abc import Generator
from http.cookiejar import MozillaCookieJar
from typing import Any, Optional
from urllib.parse import urljoin, urlparse from urllib.parse import urljoin, urlparse
from typing import Any
import click import click
import jwt
from click import Context from click import Context
from requests import Request from requests import Request
from envied.core.credential import Credential
from envied.core.manifests import HLS from envied.core.manifests import HLS
from envied.core.search_result import SearchResult from envied.core.search_result import SearchResult
from envied.core.service import Service from envied.core.service import Service
from envied.core.titles import Episode, Movie, Movies, Series from envied.core.titles import Episode, Movie, Movies, Series
from envied.core.tracks import Chapters, Subtitle, Tracks from envied.core.tracks import Chapters, Subtitle, Tracks
from envied.core.utils.xml import load_xml
class SBS(Service): class SBS(Service):
@@ -24,9 +26,9 @@ class SBS(Service):
Service code for SBS ondemand streaming service (https://www.sbs.com.au/ondemand/). Service code for SBS ondemand streaming service (https://www.sbs.com.au/ondemand/).
\b \b
Version: 1.0.1 Version: 1.0.2
Author: stabbedbybrick Author: stabbedbybrick
Authorization: None Authorization: Credentials
Geofence: AU (API and downloads) Geofence: AU (API and downloads)
Robustness: Robustness:
AES: 720p, AAC2.0 AES: 720p, AAC2.0
@@ -39,11 +41,6 @@ class SBS(Service):
MOVIE: https://www.sbs.com.au/ondemand/movie/silence/1363535939614 MOVIE: https://www.sbs.com.au/ondemand/movie/silence/1363535939614
SPORT: https://www.sbs.com.au/ondemand/sports-series/australian-championship-2025/football-australian-championship-2025/australian-championship-2025-s2025-ep40/2457638979614 SPORT: https://www.sbs.com.au/ondemand/sports-series/australian-championship-2025/football-australian-championship-2025/australian-championship-2025-s2025-ep40/2457638979614
\b
Notes:
- SBS uses transport streams for HLS, meaning the video and audio are a part of the same stream.
As a result only videos are listed as tracks, but the audio will be included as well.
""" """
GEOFENCE = ("au",) GEOFENCE = ("au",)
@@ -61,6 +58,51 @@ class SBS(Service):
self.session.headers.update(self.config["headers"]) self.session.headers.update(self.config["headers"])
def authenticate(self, cookies: Optional[MozillaCookieJar] = None, credential: Optional[Credential] = None) -> None:
super().authenticate(cookies, credential)
if not credential:
raise EnvironmentError("Service requires Credentials for Authentication.")
cache = self.cache.get(f"tokens_{credential.sha1}")
if cache and not cache.expired:
self.log.info(" + Using cached tokens")
tokens = cache.data
elif cache and cache.expired:
self.log.info("Refreshing cached tokens...")
payload = {
"refreshToken": cache.data["refreshToken"],
"deviceName": "Android TV",
"refreshTokenInBody": True,
}
r = self.session.post(
self.config["endpoints"]["refresh"],
json=payload,
)
r.raise_for_status()
tokens = r.json()
expiry = jwt.decode(tokens.get("accessToken"), options={"verify_signature": False}).get("exp")
cache.set(tokens, expiration=expiry - 60)
else:
self.log.info(" + Logging in...")
payload = {
"email": credential.username,
"password": credential.password,
"deviceName": "Android TV",
"refreshTokenInBody": True,
}
response = self.session.post(
self.config["endpoints"]["auth"], json=payload
)
response.raise_for_status()
tokens = response.json()
expiry = jwt.decode(tokens.get("accessToken"), options={"verify_signature": False}).get("exp")
cache.set(tokens, expiration=expiry - 60)
self.session.headers.update({"Authorization": "Bearer {}".format(tokens["accessToken"])})
def search(self) -> Generator[SearchResult, None, None]: def search(self) -> Generator[SearchResult, None, None]:
params = { params = {
"q": self.title.strip(), "q": self.title.strip(),
@@ -88,7 +130,7 @@ class SBS(Service):
def get_titles(self) -> Movies | Series: def get_titles(self) -> Movies | Series:
regex = re.compile( regex = re.compile(
r"^https://www.sbs.com.au/ondemand/" r"^https://www.sbs.com.au/ondemand/"
r"(?P<entity>tv-series|tv-program|sports-series|movie|watch)" r"(?P<entity>tv-series|tv-program|sports-series|sports-program|movie|watch)"
r"(?:/|/.*/)" r"(?:/|/.*/)"
r"(?P<id>[^/]+)/?$" r"(?P<id>[^/]+)/?$"
) )
@@ -99,7 +141,7 @@ class SBS(Service):
entity_type, entity_id = (match.group(i) for i in ("entity", "id")) entity_type, entity_id = (match.group(i) for i in ("entity", "id"))
if entity_type in ("movie", "tv-program") and entity_id.isdigit(): if entity_type in ("movie", "tv-program", "sports-program") and entity_id.isdigit():
movie = self._movie(entity_id) movie = self._movie(entity_id)
return Movies(movie) return Movies(movie)
@@ -112,28 +154,39 @@ class SBS(Service):
return Series(episodes) return Series(episodes)
def get_tracks(self, title: Movie | Episode) -> Tracks: def get_tracks(self, title: Movie | Episode) -> Tracks:
smil = self._request("GET", f"/api/v3/video_smil?id={title.id}") json_data = {
"deviceClass": "androidtv",
"advertising": {
"headerBidding": True,
"telariaID": "",
"subtitle": "en",
"resume": False,
},
"streamOptions": {
"audio": "demuxed",
},
}
content = self._request("POST", f"{self.config['endpoints']['playback']}/{title.id}", json=json_data)
provider = next((x for x in content.get("streamProviders", []) if x.get("type", "").lower() == "hls"), None)
body = load_xml(smil).find("body").find("seq") if not provider:
section = body.find("par") or body raise ValueError("Unable to find manifest for this title")
manifest = next((x.get("src") for x in section.findall("video")), None)
subtitles = [(x.get("src"), x.get("lang"), x.get("type")) for x in section.findall("textstream")]
manifest = provider.get("url")
tracks = HLS.from_url(manifest, self.session).to_tracks(title.language) tracks = HLS.from_url(manifest, self.session).to_tracks(title.language)
if subtitles: if subtitles := provider.get("textTracks", []):
for url, lang, type in subtitles: for subtitle in subtitles:
if "ttaf+xml" in type: url = subtitle.get("url")
continue lang = subtitle.get("lang")
codec = type.split("/")[-1] codec = subtitle.get("url").split(".")[-1]
tracks.add( tracks.add(
Subtitle( Subtitle(
id_=hashlib.md5(url.encode()).hexdigest()[0:6], id_=hashlib.md5(url.encode()).hexdigest()[0:6],
url=url, url=url,
codec=Subtitle.Codec.from_mime(codec), codec=Subtitle.Codec.from_codecs(codec),
language=lang, language=lang,
sdh="_CC" in url, sdh="CC" in subtitle.get("name", ""),
) )
) )
@@ -1,7 +1,10 @@
headers: headers:
User-Agent: "AndroidTV/!/!" User-Agent: "okhttp/4.10.0"
x-api-key: "ce3f270a-31fb-486e-855e-4f608a62640e" # mobile: "325a2c15-17ad-48d7-802e-87e8ffe0e6fe", web: 49a46461-b9eb-4904-b519-176c59c386ef
endpoints: endpoints:
base_url: "https://www.sbs.com.au" base_url: "https://www.sbs.com.au"
dai: "http://pubads.g.doubleclick.net/ondemand/hls/content/2488267/vid/{vid}/streams" playback: "https://playback.pr.sbsod.com/stream"
catalogue: "https://catalogue.pr.sbsod.com" catalogue: "https://catalogue.pr.sbsod.com"
auth: "https://auth.sbs.com.au/login"
refresh: "https://auth.sbs.com.au/refresh"