mirror of
https://github.com/vinefeeder/TwinVine.git
synced 2026-07-15 18:10:04 +02:00
updates
This commit is contained in:
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user