2026-02-22 10:41:30 +01:00

194 lines
5.8 KiB
Python

from __future__ import annotations
from http.cookiejar import MozillaCookieJar
from typing import Any, Optional
import urllib
import re
import json
import uuid
import click
from click import Context
from bs4 import BeautifulSoup
from pywidevine.cdm import Cdm as WidevineCdm
from unshackle.core.constants import AnyTrack
from unshackle.core.credential import Credential
from unshackle.core.service import Service
from unshackle.core.titles import Series, Episode, Title_T
from unshackle.core.tracks import Chapter, Tracks, Subtitle
from unshackle.core.manifests.dash import DASH
class JOYN(Service):
"""
Service code for Joyn (https://www.joyn.de)
\b
Version: 1.0.0
Author: lambda
Authorization: None
Robustness:
Widevine:
L3: 1080p, AAC2.0
"""
GEOFENCE = ("de",)
TITLE_RE = r"^https?://(?:www\.)?joyn\.de.+"
GENERIC_EPISODE_TITLE_RE = r"^Folge [0-9]+$"
@staticmethod
@click.command(name="JOYN", short_help="https://joyn.de", help=__doc__)
@click.argument("title", type=str)
@click.pass_context
def cli(ctx: Context, **kwargs: Any) -> JOYN:
return JOYN(ctx, **kwargs)
def __init__(self, ctx: Context, title: str):
self.title = title
super().__init__(ctx)
def authenticate(self, cookies: Optional[MozillaCookieJar] = None, credential: Optional[Credential] = None) -> None:
return
# r = self.session.get(self.config['endpoints']['oauth_initial'], allow_redirects=False)
# r.raise_for_status()
# redir_url = urllib.parse.urlparse(r.headers["Location"])
# redir_params = urllib.parse.parse_qs(redir_url.query)
# request_id = redir_params['requestId'][0]
# print(request_id)
# r = self.session.get(f'https://auth.7pass.de/public-srv/public/{request_id}')
# r.raise_for_status()
# print(r.json())
# r = self.session.get('https://auth.7pass.de/registration-setup-srv/public/list', params={'acceptlanguage': 'undefined', 'requestId': request_id})
# r.raise_for_status()
# print(r.json())
# r = self.session.post(f'https://auth.7pass.de/users-srv/user/checkexists/{request_id}', json={
# 'email': credential.username,
# 'requestId': request_id,
# })
# r.raise_for_status()
# print(r.json())
# r = self.session.post(self.config['endpoints']['auth_initiate'], headers={
# 'Origin': 'https://signin.7pass.de'
# }, json={
# "request_id": request_id,
# "email": credential.username,
# "medium_id": "PASSWORD",
# "usage_type": "PASSWORDLESS_AUTHENTICATION",
# "type": "PASSWORD"
# })
# r.raise_for_status()
# print(r.json())
# exit(1)
def map_episodes(self, episodes_data, season_number):
for episode in episodes_data:
episode_title = None
if not re.match(self.GENERIC_EPISODE_TITLE_RE, episode['title']):
episode_title = episode['title']
yield Episode(
id_=episode['video']['id'],
service=self.__class__,
name=episode_title,
title=episode['series']['title'].title(),
season=season_number,
number=episode['number'],
language="de",
data={'asset_id': episode['video']['id']}
)
def get_titles(self) -> Title_T:
match = re.match(self.TITLE_RE, self.title)
if not match:
return None
r = self.session.get(self.title)
r.raise_for_status()
soup = BeautifulSoup(r.text, "html.parser")
next_data = json.loads(soup.find("script", {"id": "__NEXT_DATA__"}).contents[0])
page_props = next_data['props']['pageProps']
if 'page' in page_props and page_props['page']['__typename'] == 'EpisodePage':
episodes = [page_props['page']['episode']]
season_number = page_props['page']['episode']['season']['number']
elif 'initialData' in page_props and page_props['initialData']['page']['__typename'] == 'EpisodePage':
episodes = page_props['initialData']['page']['episode']['season']['episodes']
season_number = page_props['initialData']['page']['episode']['season']['number']
else:
print('Unknown page type')
return None
return Series(self.map_episodes(episodes, season_number))
def get_tracks(self, title: Title_T) -> Tracks:
r = self.session.post(self.config['endpoints']['entitlement'], headers={
"Authorization": f'Bearer {self.config["keys"]["jwt"]}',
"joyn-b2b-context": "UNKNOWN",
"joyn-client-os": "UNKNOWN",
"joyn-client-version": "5.1387.0",
"joyn-platform": "web",
"origin": "https://www.joyn.de"
}, json={
"content_id": title.data['asset_id'],
"content_type": "VOD",
})
r.raise_for_status()
entitlement_token = r.json()["entitlement_token"]
r = self.session.post(
url=self.config['endpoints']['playlist'].format(asset_id=title.data['asset_id']),
headers={
'Authorization': f'Bearer {entitlement_token}',
},
json = {
"manufacturer": "unknown",
"platform": "browser",
"maxSecurityLevel": 1,
"streamingFormat": "dash",
"model": "unknown",
"protectionSystem": "widevine",
"enableDolbyAudio": False,
"enableSubtitles": True,
"variantName": "default"
}
)
r.raise_for_status()
playlist_data = r.json()
if playlist_data["streamingFormat"] != "dash":
print("Unknown streamingFormat:", playlist_data["streamingFormat"])
title.data['playlist_data'] = playlist_data
tracks = DASH.from_url(playlist_data["manifestUrl"], session=self.session).to_tracks('de')
return tracks
def get_chapters(self, title: Title_T) -> list[Chapter]:
return []
def get_widevine_service_certificate(self, title: Title_T, **_: Any) -> bytes:
r = self.session.get(url=title.data['playlist_data']['certificateUrl'], headers={
'Accept': '*/*',
'origin': 'https://www.joyn.de',
})
r.raise_for_status()
return r.content
def get_widevine_license(self, challenge: bytes, title: Title_T, track: AnyTrack) -> bytes:
r = self.session.post(url=title.data['playlist_data']['licenseUrl'], data=challenge, headers={
'Accept': '*/*',
'origin': 'https://www.joyn.de',
})
r.raise_for_status()
return r.content