Fix subtitle missing SKST, added VLD

This commit is contained in:
FairTrade 2026-01-12 21:25:05 +01:00
parent 54f9d4b745
commit a5c68a0dcb
4 changed files with 501 additions and 9 deletions

View File

@ -32,7 +32,9 @@
- Search functionality - Search functionality
- Fixing few hickups - Fixing few hickups
10. SKST (the hardest service I ever dealt upon now): 10. SKST (the hardest service I ever dealt upon now):
- Subtitles is a litte bit hit or miss for movies and for series there's still no subtitles - Subtitle has been fixed, hopefully no issue
11. VLD:
- So far no issue
- Acknowledgment - Acknowledgment

View File

@ -697,18 +697,14 @@ class SKST(Service):
protection = playback_data.get("protection", {}) protection = playback_data.get("protection", {})
self.drm_license_url = protection.get("licenceAcquisitionUrl") self.drm_license_url = protection.get("licenceAcquisitionUrl")
self.license_token = protection.get("licenceToken") self.license_token = protection.get("licenceToken")
manifest_url = manifest_url + "&audio=all&subtitle=all"
dash = DASH.from_url(manifest_url, session=self.session) dash = DASH.from_url(manifest_url, session=self.session)
tracks = dash.to_tracks(language=title.language) tracks = dash.to_tracks(language=title.language)
# Remove default subtitle tracks and add properly processed ones
for track in list(tracks.subtitles):
tracks.subtitles.remove(track)
subtitles = self._process_subtitles(dash, str(title.language))
tracks.add(subtitles)
return tracks return tracks
@staticmethod @staticmethod
@ -1045,4 +1041,4 @@ class SKST(Service):
# ) # )
def get_chapters(self, title: Title_T) -> list[Chapter]: def get_chapters(self, title: Title_T) -> list[Chapter]:
return [] return []

465
VLD/__init__.py Normal file
View File

@ -0,0 +1,465 @@
import re
import uuid
from collections.abc import Generator
from http.cookiejar import CookieJar
from typing import Optional, Union
import click
from langcodes import Language
from unshackle.core.constants import AnyTrack
from unshackle.core.credential import Credential
from unshackle.core.manifests import DASH
from unshackle.core.search_result import SearchResult
from unshackle.core.service import Service
from unshackle.core.titles import Episode, Movie, Movies, Series, Title_T, Titles_T
from unshackle.core.tracks import Chapter, Subtitle, Tracks
class VLD(Service):
"""
Service code for RTL's Dutch streaming service Videoland (https://v2.videoland.com)
Version: 1.0.0
Authorization: Credentials
Security:
- L1: >= 720p
- L3: <= 576p
They are using the license server of DRMToday with encoded streams from CastLabs.
It accepts Non-Whitelisted CDMs so every unrevoked L1 CDM should work.
Use full URL (for example - https://v2.videoland.com/title-p_12345) or title slug.
"""
ALIASES = ("VLD", "videoland")
TITLE_RE = r"^(?:https?://(?:www\.)?v2\.videoland\.com/)?(?P<title_id>[a-zA-Z0-9_-]+)"
GEOFENCE = ("NL",)
@staticmethod
@click.command(name="Videoland", short_help="https://v2.videoland.com")
@click.argument("title", type=str)
@click.option("-m", "--movie", is_flag=True, default=False, help="Specify if it's a movie")
@click.pass_context
def cli(ctx, **kwargs):
return VLD(ctx, **kwargs)
def __init__(self, ctx, title, movie):
super().__init__(ctx)
self.title = title
self.movie = movie
self.cdm = ctx.obj.cdm
self.device_id = str(uuid.uuid1().int)
if self.config is None:
raise Exception("Config is missing!")
profile_name = ctx.parent.params.get("profile")
self.profile = profile_name if profile_name else "default"
self.platform = self.config["platform"]["android_tv"]
self.platform_token = "token-androidtv-3"
def authenticate(self, cookies: Optional[CookieJar] = None, credential: Optional[Credential] = None) -> None:
super().authenticate(cookies, credential)
if not credential or not credential.username or not credential.password:
raise EnvironmentError("Service requires Credentials for Authentication.")
self.credential = credential # Store for potential re-auth
self.session.headers.update({
"origin": "https://v2.videoland.com",
"x-client-release": self.config["sdk"]["version"],
"x-customer-name": "rtlnl",
})
# Build cache key
cache_key = f"tokens_{self.profile}"
# Check cache first
cache = self.cache.get(cache_key)
if cache and not cache.expired:
cached_data = cache.data
if isinstance(cached_data, dict) and cached_data.get("username") == credential.username:
self.log.info("Using cached tokens")
self._restore_from_cache(cached_data)
return
# Perform fresh login
self.log.info("Retrieving new tokens")
self._do_login(credential)
# Cache the tokens
self._cache_tokens(credential.username, cache_key)
def _restore_from_cache(self, cached_data: dict) -> None:
"""Restore authentication state from cached data."""
self.access_token = cached_data["access_token"]
self.gigya_uid = cached_data["gigya_uid"]
self.profile_id = cached_data["profile_id"]
self.session.headers.update({"Authorization": f"Bearer {self.access_token}"})
def _cache_tokens(self, username: str, cache_key: str) -> None:
"""Cache the current authentication tokens."""
cache = self.cache.get(cache_key)
cache.set(
data={
"username": username,
"access_token": self.access_token,
"gigya_uid": self.gigya_uid,
"profile_id": self.profile_id,
},
expiration=3600 # 1 hour expiration, adjust as needed
)
def _do_login(self, credential: Credential) -> None:
"""Perform full login flow."""
# Step 1: Authorize with Gigya
auth_response = self.session.post(
url=self.config["endpoints"]["authorization"],
data={
"loginID": credential.username,
"password": credential.password,
"sessionExpiration": "0",
"targetEnv": "jssdk",
"include": "profile,data",
"includeUserInfo": "true",
"lang": "nl",
"ApiKey": self.config["sdk"]["apikey"],
"authMode": "cookie",
"pageURL": "https://v2.videoland.com/",
"sdkBuild": self.config["sdk"]["build"],
"format": "json",
},
).json()
if auth_response.get("errorMessage"):
raise EnvironmentError(f"Could not authorize Videoland account: {auth_response['errorMessage']!r}")
self.gigya_uid = auth_response["UID"]
uid_signature = auth_response["UIDSignature"]
signature_timestamp = auth_response["signatureTimestamp"]
# Step 2: Get initial JWT token
jwt_headers = {
"x-auth-device-id": self.device_id,
"x-auth-device-player-size-height": "3840",
"x-auth-device-player-size-width": "2160",
"X-Auth-gigya-signature": uid_signature,
"X-Auth-gigya-signature-timestamp": signature_timestamp,
"X-Auth-gigya-uid": self.gigya_uid,
"X-Client-Release": self.config["sdk"]["version"],
"X-Customer-Name": "rtlnl",
}
jwt_response = self.session.get(
url=self.config["endpoints"]["jwt_tokens"].format(platform=self.platform),
headers=jwt_headers,
).json()
if jwt_response.get("error"):
raise EnvironmentError(f"Could not get Access Token: {jwt_response['error']['message']!r}")
initial_token = jwt_response["token"]
# Step 3: Get profiles
profiles_response = self.session.get(
url=self.config["endpoints"]["profiles"].format(
platform=self.platform,
gigya=self.gigya_uid,
),
headers={"Authorization": f"Bearer {initial_token}"},
).json()
if isinstance(profiles_response, dict) and profiles_response.get("error"):
raise EnvironmentError(f"Could not get profiles: {profiles_response['error']['message']!r}")
self.profile_id = profiles_response[0]["uid"]
# Step 4: Get final JWT token with profile
jwt_headers["X-Auth-profile-id"] = self.profile_id
final_jwt_response = self.session.get(
url=self.config["endpoints"]["jwt_tokens"].format(platform=self.platform),
headers=jwt_headers,
).json()
if final_jwt_response.get("error"):
raise EnvironmentError(f"Could not get final Access Token: {final_jwt_response['error']['message']!r}")
self.access_token = final_jwt_response["token"]
self.session.headers.update({"Authorization": f"Bearer {self.access_token}"})
def search(self) -> Generator[SearchResult, None, None]:
# Videoland doesn't have a documented search endpoint in the original code
# This is a placeholder - you may need to implement based on actual API
raise NotImplementedError("Search is not implemented for Videoland")
def get_titles(self) -> Titles_T:
title_match = re.match(self.TITLE_RE, self.title)
if not title_match:
raise ValueError(f"Invalid title format: {self.title}")
title_slug = title_match.group("title_id")
# Handle folder URLs (e.g., title-f_12345)
if re.match(r".+?-f_[0-9]+", title_slug):
title_slug = self._get_program_title(title_slug)
# Extract title ID from slug (e.g., "show-name-p_12345" -> "12345")
title_id = title_slug.split("-p_")[-1] if "-p_" in title_slug else title_slug
metadata = self.session.get(
url=self.config["endpoints"]["layout"].format(
platform=self.platform,
token=self.platform_token,
endpoint=f"program/{title_id}",
),
params={"nbPages": "10"},
).json()
# Check for API errors
if isinstance(metadata, dict) and metadata.get("error"):
raise ValueError(f"API Error: {metadata.get('message', 'Unknown error')}")
# Determine if it's a movie based on metadata
is_movie = "Seizoen" not in str(metadata)
if is_movie:
movie_info = metadata["blocks"][0]["content"]["items"][0]
viewable_id = movie_info["itemContent"]["action"]["target"]["value_layout"]["id"]
return Movies([
Movie(
id_=movie_info["ucid"],
service=self.__class__,
name=metadata["entity"]["metadata"]["title"],
year=None,
language=Language.get("nl"),
data={
"viewable": viewable_id,
"metadata": metadata,
},
)
])
else:
seasons = [
block
for block in metadata["blocks"]
if block["featureId"] == "videos_by_season_by_program"
]
# Fetch all episodes from all seasons with pagination
for season in seasons:
while len(season["content"]["items"]) != season["content"]["pagination"]["totalItems"]:
season_data = self.session.get(
url=self.config["endpoints"]["seasoning"].format(
platform=self.platform,
token=self.platform_token,
program=title_id,
season_id=season["id"],
),
params={
"nbPages": "10",
"page": season["content"]["pagination"]["nextPage"],
},
).json()
for episode in season_data["content"]["items"]:
if episode not in season["content"]["items"]:
season["content"]["items"].append(episode)
season["content"]["pagination"]["nextPage"] = season_data["content"]["pagination"]["nextPage"]
episodes = []
for season in seasons:
# Extract season number from title like "Seizoen 1" or "Season 1"
season_title = season.get("title", {}).get("long", "")
season_match = re.search(r"(\d+)", season_title)
season_number = int(season_match.group(1)) if season_match else 1
for idx, episode_data in enumerate(season["content"]["items"]):
# Get the extra title which contains episode info
extra_title = episode_data["itemContent"].get("extraTitle", "")
# Extract episode number from extraTitle like "1. Hondenadoptiedag" or "14. Een Draak Op School (Deel 1)"
episode_number = None
episode_name = extra_title
ep_match = re.match(r"^(\d+)\.\s*(.*)$", extra_title)
if ep_match:
episode_number = int(ep_match.group(1))
episode_name = ep_match.group(2)
else:
# Fallback to index + 1
episode_number = idx + 1
viewable_id = episode_data["itemContent"]["action"]["target"]["value_layout"]["id"]
episodes.append(
Episode(
id_=episode_data["ucid"],
service=self.__class__,
title=metadata["entity"]["metadata"]["title"],
season=season_number,
number=episode_number,
name=episode_name,
year=None,
language=Language.get("nl"),
data={
"viewable": viewable_id,
"episode_data": episode_data,
},
)
)
# Sort episodes by season and episode number
episodes = sorted(episodes, key=lambda ep: (ep.season, ep.number))
return Series(episodes)
def get_tracks(self, title: Title_T) -> Tracks:
viewable_id = title.data["viewable"]
manifest_response = self.session.get(
url=self.config["endpoints"]["layout"].format(
platform=self.platform,
token=self.platform_token,
endpoint=f"video/{viewable_id}",
),
params={"nbPages": "2"},
).json()
player_block = next(
(block for block in manifest_response["blocks"] if block["templateId"] == "Player"),
None,
)
if not player_block:
raise ValueError("Could not find player block in manifest")
assets = player_block["content"]["items"][0]["itemContent"]["video"]["assets"]
if not assets:
raise ValueError("Failed to load content manifest - no assets found")
# Prefer HD quality
mpd_asset = next((asset for asset in assets if asset["quality"] == "hd"), None)
if not mpd_asset:
mpd_asset = next((asset for asset in assets if asset["quality"] == "sd"), None)
if not mpd_asset:
raise ValueError("No suitable quality stream found")
mpd_url = mpd_asset["path"]
# Extract PlayReady PSSH from manifest
manifest_content = self.session.get(mpd_url).text
pssh_matches = re.findall(r'<cenc:pssh>(.+?)</cenc:pssh>', manifest_content)
self.pssh_playready = None
for pssh in pssh_matches:
if len(pssh) > 200:
self.pssh_playready = pssh
break
# Store viewable ID for license request
self.current_viewable = viewable_id
tracks = DASH.from_url(url=mpd_url, session=self.session).to_tracks(language=title.language)
# Fix track URLs - replace CDN hostname
for track in tracks:
if hasattr(track, 'url') and track.url:
if isinstance(track.url, list):
track.url = [
re.sub(
r"https://.+?\.videoland\.bedrock\.tech",
"https://origin.vod.videoland.bedrock.tech",
uri.split("?")[0],
)
for uri in track.url
]
elif isinstance(track.url, str):
track.url = re.sub(
r"https://.+?\.videoland\.bedrock\.tech",
"https://origin.vod.videoland.bedrock.tech",
track.url.split("?")[0],
)
# Handle subtitles
for subtitle in tracks.subtitles:
if isinstance(subtitle.url, list) or (isinstance(subtitle.url, str) and "dash" in subtitle.url):
subtitle.codec = Subtitle.Codec.SubRip
else:
self.log.warning("Unknown subtitle codec detected")
return tracks
def get_chapters(self, title: Title_T) -> list[Chapter]:
return []
def get_widevine_service_certificate(self, **_) -> Optional[str]:
return self.config.get("certificate")
def get_widevine_license(self, *, challenge: bytes, title: Title_T, track: AnyTrack) -> Optional[Union[bytes, str]]:
license_token = self._get_license_token(title)
response = self.session.post(
url=self.config["endpoints"]["license_wv"],
data=challenge,
headers={"x-dt-auth-token": license_token},
)
if response.status_code != 200:
raise ValueError(f"Failed to get Widevine license: {response.status_code}")
return response.json().get("license")
def get_playready_license(self, *, challenge: bytes, title: Title_T, track: AnyTrack) -> Optional[bytes]:
license_token = self._get_license_token(title)
response = self.session.post(
url=self.config["endpoints"]["license_pr"],
data=challenge,
headers={"x-dt-auth-token": license_token},
)
if response.status_code != 200:
raise ValueError(f"Failed to get PlayReady license: {response.status_code}")
return response.content
def _get_license_token(self, title: Title_T) -> str:
viewable_id = title.data["viewable"]
response = self.session.get(
url=self.config["endpoints"]["license_token"].format(
platform=self.platform,
gigya=self.gigya_uid,
clip=viewable_id,
),
).json()
return response["token"]
def _get_program_title(self, folder_title: str) -> str:
folder_id = folder_title.split("-f_")[1]
response = self.session.get(
url=self.config["endpoints"]["layout"].format(
platform=self.platform,
token=self.platform_token,
endpoint=f"folder/{folder_id}",
),
params={"nbPages": "2"},
).json()
target = response["blocks"][0]["content"]["items"][0]["itemContent"]["action"]["target"]["value_layout"]
parent_seo = target["parent"]["seo"]
parent_id = target["parent"]["id"]
return f"{parent_seo}-p_{parent_id}"

29
VLD/config.yaml Normal file
View File

@ -0,0 +1,29 @@
certificate: |
CsECCAMSEBcFuRfMEgSGiwYzOi93KowYgrSCkgUijgIwggEKAoIBAQCZ7Vs7Mn2rXiTvw7YqlbWYUgrVvMs3UD4GRbgU2Ha430BRBEGtjOOtsRu4jE5yWl5
KngeVKR1YWEAjp+GvDjipEnk5MAhhC28VjIeMfiG/+/7qd+EBnh5XgeikX0YmPRTmDoBYqGB63OBPrIRXsTeo1nzN6zNwXZg6IftO7L1KEMpHSQykfqpdQ4
IY3brxyt4zkvE9b/tkQv0x4b9AsMYE0cS6TJUgpL+X7r1gkpr87vVbuvVk4tDnbNfFXHOggrmWEguDWe3OJHBwgmgNb2fG2CxKxfMTRJCnTuw3r0svAQxZ6
ChD4lgvC2ufXbD8Xm7fZPvTCLRxG88SUAGcn1oJAgMBAAE6FGxpY2Vuc2Uud2lkZXZpbmUuY29tEoADrjRzFLWoNSl/JxOI+3u4y1J30kmCPN3R2jC5MzlR
HrPMveoEuUS5J8EhNG79verJ1BORfm7BdqEEOEYKUDvBlSubpOTOD8S/wgqYCKqvS/zRnB3PzfV0zKwo0bQQQWz53ogEMBy9szTK/NDUCXhCOmQuVGE98K/
PlspKkknYVeQrOnA+8XZ/apvTbWv4K+drvwy6T95Z0qvMdv62Qke4XEMfvKUiZrYZ/DaXlUP8qcu9u/r6DhpV51Wjx7zmVflkb1gquc9wqgi5efhn9joLK3
/bNixbxOzVVdhbyqnFk8ODyFfUnaq3fkC3hR3f0kmYgI41sljnXXjqwMoW9wRzBMINk+3k6P8cbxfmJD4/Paj8FwmHDsRfuoI6Jj8M76H3CTsZCZKDJjM3B
QQ6Kb2m+bQ0LMjfVDyxoRgvfF//M/EEkPrKWyU2C3YBXpxaBquO4C8A0ujVmGEEqsxN1HX9lu6c5OMm8huDxwWFd7OHMs3avGpr7RP7DUnTikXrh6X0
endpoints:
layout: https://layout.videoland.bedrock.tech/front/v1/rtlnl/{platform}/main/{token}/{endpoint}/layout
seasoning: https://layout.videoland.bedrock.tech/front/v1/rtlnl/{platform}/main/{token}/program/{program}/block/{season_id}
license_pr: https://lic.drmtoday.com/license-proxy-headerauth/drmtoday/RightsManager.asmx
license_wv: https://lic.drmtoday.com/license-proxy-widevine/cenc/
license_token: https://drm.videoland.bedrock.tech/v1/customers/rtlnl/platforms/{platform}/services/videoland/users/{gigya}/videos/{clip}/upfront-token
authorization: https://accounts.eu1.gigya.com/accounts.login
jwt_tokens: https://front-auth.videoland.bedrock.tech/v2/platforms/{platform}/getJwt
profiles: https://users.videoland.bedrock.tech/v2/platforms/{platform}/users/{gigya}/profiles
platform:
web: m6group_web
android_mob: m6group_android_mob
android_tv: m6group_android_tv
sdk:
apikey: 3_W6BPwMz2FGQEfH4_nVRaj4Ak1F1XDp33an_8y8nXULn8nk43FHvPIpb0TLOYIaUI
build: "13414"
version: 5.47.2