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
from bs4 import BeautifulSoup
from click import Context
from envied.core.cdm.detect import is_playready_cdm
from envied.core.credential import Credential
from envied.core.manifests.dash import DASH
from envied.core.search_result import SearchResult
@@ -24,11 +25,16 @@ class ITV(Service):
Service code for ITVx streaming service (https://www.itv.com/).
\b
Version: 1.0.3
Version: 1.0.4
Author: stabbedbybrick
Authorization: Cookies (Optional for free content | Required for premium content)
Robustness:
L3: 1080p
Widevine:
L1: 1080p
L3: 720p
PlayReady:
SL3000: 1080p
SL2000: 720p
\b
Tips:
@@ -45,11 +51,6 @@ class ITV(Service):
- 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",)
@@ -70,6 +71,10 @@ class ITV(Service):
if not self.profile:
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"])
def authenticate(self, cookies: Optional[MozillaCookieJar] = None, credential: Optional[Credential] = None) -> None:
@@ -261,15 +266,15 @@ class ITV(Service):
"player": "dash",
"featureset": [
"mpeg-dash",
"widevine",
self.drm_system,
"outband-webvtt",
"hd",
"single-track",
],
"platformTag": "ctv",
"drm": {
"system": "widevine",
"maxSupported": "L3",
"system": self.drm_system,
"maxSupported": self.security_level,
},
},
}
@@ -327,6 +332,12 @@ class ITV(Service):
if r.status_code != 200:
raise ConnectionError(r.text)
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
@@ -4,18 +4,20 @@ import hashlib
import json
import re
from collections.abc import Generator
from http.cookiejar import MozillaCookieJar
from typing import Any, Optional
from urllib.parse import urljoin, urlparse
from typing import Any
import click
import jwt
from click import Context
from requests import Request
from envied.core.credential import Credential
from envied.core.manifests import HLS
from envied.core.search_result import SearchResult
from envied.core.service import Service
from envied.core.titles import Episode, Movie, Movies, Series
from envied.core.tracks import Chapters, Subtitle, Tracks
from envied.core.utils.xml import load_xml
class SBS(Service):
@@ -24,9 +26,9 @@ class SBS(Service):
Service code for SBS ondemand streaming service (https://www.sbs.com.au/ondemand/).
\b
Version: 1.0.1
Version: 1.0.2
Author: stabbedbybrick
Authorization: None
Authorization: Credentials
Geofence: AU (API and downloads)
Robustness:
AES: 720p, AAC2.0
@@ -39,11 +41,6 @@ class SBS(Service):
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
\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",)
@@ -61,6 +58,51 @@ class SBS(Service):
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]:
params = {
"q": self.title.strip(),
@@ -88,7 +130,7 @@ class SBS(Service):
def get_titles(self) -> Movies | Series:
regex = re.compile(
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"(?P<id>[^/]+)/?$"
)
@@ -99,7 +141,7 @@ class SBS(Service):
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)
return Movies(movie)
@@ -112,28 +154,39 @@ class SBS(Service):
return Series(episodes)
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")
section = body.find("par") or body
if not provider:
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)
if subtitles:
for url, lang, type in subtitles:
if "ttaf+xml" in type:
continue
codec = type.split("/")[-1]
if subtitles := provider.get("textTracks", []):
for subtitle in subtitles:
url = subtitle.get("url")
lang = subtitle.get("lang")
codec = subtitle.get("url").split(".")[-1]
tracks.add(
Subtitle(
id_=hashlib.md5(url.encode()).hexdigest()[0:6],
url=url,
codec=Subtitle.Codec.from_mime(codec),
codec=Subtitle.Codec.from_codecs(codec),
language=lang,
sdh="_CC" in url,
sdh="CC" in subtitle.get("name", ""),
)
)
@@ -1,7 +1,10 @@
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:
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"
auth: "https://auth.sbs.com.au/login"
refresh: "https://auth.sbs.com.au/refresh"