Added Services Telasa thanks to NSBC
This commit is contained in:
parent
eb478d9e75
commit
b4cea7b7c3
404
TELASA/__init__.py
Normal file
404
TELASA/__init__.py
Normal file
@ -0,0 +1,404 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from hashlib import md5
|
||||
from typing import Any, Optional, Union, List
|
||||
|
||||
import click
|
||||
from click import Context
|
||||
from langcodes import Language
|
||||
from requests import Request
|
||||
|
||||
from unshackle.core.constants import AnyTrack
|
||||
from unshackle.core.service import Service
|
||||
from unshackle.core.manifests import HLS, DASH
|
||||
from unshackle.core.titles import Title_T, Titles_T, Episode, Movie, Movies, Series
|
||||
from unshackle.core.tracks import Chapters, Tracks, Audio, Video, Subtitle
|
||||
from unshackle.core.utils.collections import as_list
|
||||
|
||||
class TELASA(Service):
|
||||
"""
|
||||
Service code for Telasa (https://www.telasa.jp/).
|
||||
\b
|
||||
Authorization: Cookies
|
||||
Security:
|
||||
Widevine:
|
||||
L3: 1080p (Possibly 2160p?)
|
||||
playReady:
|
||||
SL2000: 1080p
|
||||
Author: Crash@NSBC (Ported to Unshackle)
|
||||
"""
|
||||
|
||||
ALIASES = ("TLSA", "telasa")
|
||||
TITLE_RE = r"^(?:https?://(?:www\.)?telasa\.jp)/(?P<type>series|videos)/(?P<id>\d+)"
|
||||
|
||||
@staticmethod
|
||||
@click.command(name="Telasa", short_help="https://www.telasa.jp/")
|
||||
@click.argument("title", type=str)
|
||||
@click.option("-s", "--single", is_flag=True, default=False, help="Download only the specific ID provided (do not fetch other seasons).")
|
||||
@click.option("-t", "--title", "force_title", type=str, default=None, help="Force a custom series/movie title.")
|
||||
@click.pass_context
|
||||
def cli(ctx: Context, **kwargs: Any) -> Telasa:
|
||||
return TELASA(ctx, **kwargs)
|
||||
|
||||
def __init__(self, ctx: Context, title: str, single: bool, force_title: str | None = None):
|
||||
self.title_input = title
|
||||
super().__init__(ctx)
|
||||
|
||||
self.single = single
|
||||
self.force_title = force_title
|
||||
|
||||
parse = re.search(self.TITLE_RE, self.title_input)
|
||||
if not parse:
|
||||
self.title_id = self.title_input
|
||||
self.is_movie = True
|
||||
else:
|
||||
self.title_id = parse.group("id")
|
||||
self.is_movie = bool(parse.group("type") == "videos")
|
||||
|
||||
self.cdm = ctx.obj.cdm
|
||||
self.session.headers.update({
|
||||
"Origin": "https://www.telasa.jp",
|
||||
"Referer": "https://www.telasa.jp/",
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
||||
})
|
||||
|
||||
def authenticate(self, cookies: Optional[Any] = None, credential: Optional[Any] = None) -> None:
|
||||
if not cookies:
|
||||
self.log.error("Service requires Cookies for Authentication.", exc_info=False)
|
||||
sys.exit(1)
|
||||
|
||||
super().authenticate(cookies, credential)
|
||||
|
||||
self.log.info("Logging into Telasa...")
|
||||
|
||||
last_auth_user = next(
|
||||
(cookie.value for cookie in self.session.cookies if cookie.name.endswith("LastAuthUser")),
|
||||
None,
|
||||
)
|
||||
if not last_auth_user:
|
||||
self.log.error("- LastAuthUser not Found in cookies.", exc_info=False)
|
||||
import sys; sys.exit(1)
|
||||
|
||||
prefix = "CognitoIdentityServiceProvider.71t3jfmis742ffmevo4677evms"
|
||||
access_token_cookie = next(
|
||||
(cookie for cookie in self.session.cookies if cookie.name == f"{prefix}.{last_auth_user}.accessToken"),
|
||||
None,
|
||||
)
|
||||
|
||||
if not access_token_cookie:
|
||||
self.log.error("- Access Token not Found in cookies.", exc_info=False)
|
||||
import sys; sys.exit(1)
|
||||
|
||||
access_token = access_token_cookie.value
|
||||
|
||||
# Validate/Refresh Token logic
|
||||
req = self.session.post(
|
||||
url=self.config["endpoints"]["auth"],
|
||||
headers={
|
||||
"Content-Type": "application/x-amz-json-1.1",
|
||||
"X-Amz-Target": "AWSCognitoIdentityProviderService.GetUser",
|
||||
"X-Amz-User-Agent": "aws-amplify/5.0.4 auth framework/2",
|
||||
},
|
||||
json={"AccessToken": access_token},
|
||||
)
|
||||
|
||||
data = req.json()
|
||||
|
||||
if data.get("__type") == "NotAuthorizedException":
|
||||
self.log.info(" - Access Token expired, refreshing...")
|
||||
refresh_token = next(
|
||||
(cookie for cookie in self.session.cookies if cookie.name == f"{prefix}.{last_auth_user}.refreshToken"),
|
||||
None,
|
||||
)
|
||||
|
||||
if not refresh_token:
|
||||
self.log.error("- Refresh Token not Found.", exc_info=False)
|
||||
import sys; sys.exit(1)
|
||||
|
||||
req = self.session.post(
|
||||
url=self.config["endpoints"]["auth"],
|
||||
headers={
|
||||
"Content-Type": "application/x-amz-json-1.1",
|
||||
"x-amz-Target": "AWSCognitoIdentityProviderService.InitiateAuth",
|
||||
"x-amz-User-Agent": "aws-amplify/5.0.4 auth framework/2",
|
||||
},
|
||||
json={
|
||||
"ClientId": "71t3jfmis742ffmevo4677evms",
|
||||
"AuthFlow": "REFRESH_TOKEN_AUTH",
|
||||
"AuthParameters": {
|
||||
"REFRESH_TOKEN": refresh_token.value,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
data = req.json()
|
||||
if data.get("__type") == "NotAuthorizedException":
|
||||
self.log.error(f" - Failed to Refresh Access Token -> {data.get('message')}", exc_info=False)
|
||||
import sys; sys.exit(1)
|
||||
|
||||
access_token = data["AuthenticationResult"]["AccessToken"]
|
||||
|
||||
device_id = next(
|
||||
iter(cookie for cookie in self.session.cookies if cookie.name == "did"),
|
||||
None,
|
||||
)
|
||||
|
||||
if not device_id:
|
||||
self.log.error(" - Device ID (did) not Found in cookies.", exc_info=False)
|
||||
import sys; sys.exit(1)
|
||||
|
||||
self.session.headers.update({
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
"X-Device-Id": device_id.value,
|
||||
})
|
||||
self.log.info(" + Authentication Successful.")
|
||||
|
||||
def get_titles(self) -> Titles_T:
|
||||
if self.is_movie:
|
||||
return self._get_movie_titles()
|
||||
else:
|
||||
return self._get_series_titles()
|
||||
|
||||
def _get_movie_titles(self) -> Movies:
|
||||
req = self.session.get(self.config["endpoints"]["base"].format(id=self.title_id))
|
||||
data_match = re.search(r'<script id=\"__NEXT_DATA__" type=\"application/json\">(.+?)<\/script>', req.text)
|
||||
|
||||
if not data_match:
|
||||
raise ValueError("Could not parse ID from Title.")
|
||||
|
||||
data = json.loads(data_match.group(1).strip())
|
||||
video_detail = data["props"]["initialState"]["videoDetail"]["video"]
|
||||
series_detail = data["props"]["initialState"]["seriesDetail"]["series"]
|
||||
|
||||
|
||||
if series_detail is None:
|
||||
return Movies([
|
||||
Movie(
|
||||
id_=video_detail["id"],
|
||||
service=self.__class__,
|
||||
name=self.force_title or video_detail["name"],
|
||||
year=video_detail.get("year_of_production"),
|
||||
data=video_detail
|
||||
)
|
||||
])
|
||||
|
||||
all_episodes = self._process_episodes(
|
||||
series_detail["episode_ids"],
|
||||
self.force_title or series_detail["name"],
|
||||
data.get("data", {}).get("season_sequence_number", 1)
|
||||
)
|
||||
|
||||
if not self.single and "seasons" in series_detail:
|
||||
for season in series_detail["seasons"]:
|
||||
if str(season["series_id"]) == str(series_detail["id"]):
|
||||
continue
|
||||
|
||||
req_season = self.session.get(self.config["endpoints"]["series"].format(id=season["series_id"]))
|
||||
if req_season.ok:
|
||||
s_data = req_season.json()
|
||||
extra_eps = self._process_episodes(
|
||||
s_data["data"]["episode_ids"],
|
||||
self.force_title or s_data["data"]["name"],
|
||||
s_data.get("data", {}).get("season_sequence_number")
|
||||
)
|
||||
all_episodes.extend(extra_eps)
|
||||
|
||||
return Series(all_episodes)
|
||||
|
||||
def _get_series_titles(self) -> Series:
|
||||
req = self.session.get(self.config["endpoints"]["series"].format(id=self.title_id))
|
||||
data = req.json()
|
||||
|
||||
all_episodes = self._process_episodes(
|
||||
data["data"]["episode_ids"],
|
||||
self.force_title or data["data"]["name"],
|
||||
data.get("data", {}).get("season_sequence_number", 1)
|
||||
)
|
||||
|
||||
if not self.single and "seasons" in data["data"]:
|
||||
for season in data["data"]["seasons"]:
|
||||
if str(season["series_id"]) == str(self.title_id):
|
||||
continue
|
||||
|
||||
req_season = self.session.get(self.config["endpoints"]["series"].format(id=season["series_id"]))
|
||||
if req_season.ok:
|
||||
s_data = req_season.json()
|
||||
extra_eps = self._process_episodes(
|
||||
s_data["data"]["episode_ids"],
|
||||
self.force_title or s_data["data"]["name"],
|
||||
s_data.get("data", {}).get("season_sequence_number")
|
||||
)
|
||||
all_episodes.extend(extra_eps)
|
||||
|
||||
return Series(all_episodes)
|
||||
|
||||
def _process_episodes(self, video_ids: list, series_name: str, season_number: int) -> list[Episode]:
|
||||
req = self.session.post(
|
||||
url=self.config["endpoints"]["episode"],
|
||||
json={"video_ids": video_ids}
|
||||
)
|
||||
episodes_json = req.json()
|
||||
|
||||
results = []
|
||||
skip_keywords = ["PR", "予告", "ダイジェスト", "特報", "解説放送"]
|
||||
|
||||
for number, item in enumerate(episodes_json["data"]["items"]):
|
||||
episode_data = item.get("data", {})
|
||||
name = episode_data.get("name", "")
|
||||
subtitle = episode_data.get("subtitle", "")
|
||||
|
||||
full_title_info = f"{name} {subtitle}"
|
||||
if any(keyword in full_title_info for keyword in skip_keywords):
|
||||
continue
|
||||
|
||||
episode_number_match = re.search(r"第(\d+)話", name)
|
||||
ep_num = int(episode_number_match.group(1)) if episode_number_match else (number + 1)
|
||||
|
||||
results.append(
|
||||
Episode(
|
||||
id_=episode_data["id"],
|
||||
service=self.__class__,
|
||||
title=series_name,
|
||||
season=season_number if season_number is not None else 1,
|
||||
number=ep_num,
|
||||
name=subtitle,
|
||||
year=episode_data.get("year_of_production"),
|
||||
data=episode_data
|
||||
)
|
||||
)
|
||||
return results
|
||||
|
||||
def get_tracks(self, title: Title_T) -> Tracks:
|
||||
req = self.session.post(
|
||||
url=self.config["endpoints"]["graphql"],
|
||||
json={
|
||||
"query": f'{{ playbackToken( item_id: "{title.id}", item_type: Mezzanine ) {{ token expires_at license_id }} }}'
|
||||
},
|
||||
)
|
||||
data = req.json()
|
||||
if "errors" in data:
|
||||
self.log.error(f"Failed to Get Playback Token -> {data['errors'][0]['message']}", exc_info=False)
|
||||
import sys; sys.exit(1)
|
||||
|
||||
playback_token = data["data"]["playbackToken"]["token"]
|
||||
|
||||
req = self.session.post(
|
||||
url=self.config["endpoints"]["graphql"],
|
||||
headers={
|
||||
"X-Device-Id": self.session.headers.get("X-Device-Id", "979e52a4-d9aa-4dbb-b97b-f33434aa5aa7"),
|
||||
},
|
||||
json={
|
||||
"query": f'{{ manifests(item_id: "{title.id}", item_type: Mezzanine, playback_token: "{playback_token}") {{ protocol items {{ name url }} }} subtitles(id: "{title.id}", playback_token: "{playback_token}") {{ language url }} mezzanine(id: "{title.id}") {{ id title time {{ last_played duration endStart }} recommend {{ previous {{ id title images {{ url }} }} next {{ id title images {{ url }} }} }} video {{ id }} }} thumbnailSeekings(id: "{title.id}", playback_token: "{playback_token}") {{ quality url }} }}'
|
||||
},
|
||||
)
|
||||
data = req.json()
|
||||
|
||||
manifest = next(
|
||||
(x for x in data["data"]["manifests"] if x["protocol"] == "dash"),
|
||||
None,
|
||||
)
|
||||
|
||||
tracks = Tracks()
|
||||
|
||||
if manifest["protocol"] == "hls":
|
||||
manifest_url = manifest["items"][0]["url"]
|
||||
tracks.add(HLS.from_url(manifest_url, self.session).to_tracks(language="ja"))
|
||||
else:
|
||||
manifest_url = manifest["items"][-1]["url"]
|
||||
tracks.add(DASH.from_url(manifest_url, self.session).to_tracks(language="ja"))
|
||||
|
||||
|
||||
for track in tracks:
|
||||
track.extra = {"playback_token": playback_token}
|
||||
if isinstance(track, Audio):
|
||||
track.language = Language.get("ja")
|
||||
|
||||
if tracks.audio:
|
||||
tracks.audio.sort(key=lambda x: x.bitrate or 0, reverse=True)
|
||||
best_audio = tracks.audio[0]
|
||||
tracks.audio = [best_audio]
|
||||
|
||||
if data.get("data", {}).get("subtitles"):
|
||||
for sub in data["data"]["subtitles"]:
|
||||
subtitle_url = sub.get("url")
|
||||
lang_code = sub.get("language", "ja")
|
||||
|
||||
if not subtitle_url:
|
||||
continue
|
||||
|
||||
try:
|
||||
cek = self.session.get(url=subtitle_url)
|
||||
content_text = cek.text.strip()
|
||||
if not cek.ok or not content_text.startswith("WEBVTT"):
|
||||
continue
|
||||
except Exception as e:
|
||||
continue
|
||||
|
||||
track_id = md5(subtitle_url.encode()).hexdigest()[0:6]
|
||||
|
||||
tracks.add(
|
||||
Subtitle(
|
||||
id_=track_id,
|
||||
url=subtitle_url,
|
||||
codec=Subtitle.Codec.WebVTT,
|
||||
language=Language.get(lang_code),
|
||||
forced=False,
|
||||
sdh=False,
|
||||
)
|
||||
)
|
||||
|
||||
return tracks
|
||||
|
||||
def get_chapters(self, title: Title_T) -> Chapters:
|
||||
return []
|
||||
|
||||
def get_widevine_service_certificate(self, **kwargs) -> Optional[bytes]:
|
||||
return None
|
||||
|
||||
def get_widevine_license(self, challenge: bytes, track: AnyTrack, **kwargs) -> Optional[bytes]:
|
||||
try:
|
||||
token = track.extra.get("playback_token")
|
||||
if not token:
|
||||
self.log.error("License Error: 'playback_token' not found in track data.")
|
||||
return None
|
||||
|
||||
req = self.session.post(
|
||||
url="https://license.kddi-video.com/",
|
||||
data=challenge,
|
||||
headers={
|
||||
"X-Custom-Data": f"token_type=playback&token_value={token}&widevine_security_level=L3"
|
||||
},
|
||||
)
|
||||
|
||||
if not req.ok:
|
||||
self.log.error(f"License Request Failed: HTTP {req.status_code}")
|
||||
self.log.debug(f"Server Response: {req.text}")
|
||||
return None
|
||||
|
||||
return req.content
|
||||
|
||||
except Exception as e:
|
||||
self.log.error(f"Exception during license request: {e}")
|
||||
return None
|
||||
|
||||
def get_playready_license(self, challenge: bytes, track: AnyTrack, **kwargs) -> Optional[bytes]:
|
||||
try:
|
||||
token = track.extra.get("playback_token")
|
||||
if not token:
|
||||
return None
|
||||
|
||||
req = self.session.post(
|
||||
url=self.config["endpoints"]["license"]["playready"],
|
||||
data=challenge,
|
||||
headers={
|
||||
"X-Custom-Data": f"token_type=playback&token_value={token}&playready_security_level=SL2000"
|
||||
},
|
||||
)
|
||||
if not req.ok:
|
||||
return None
|
||||
return req.content
|
||||
except Exception:
|
||||
return None
|
||||
10
TELASA/config.yaml
Normal file
10
TELASA/config.yaml
Normal file
@ -0,0 +1,10 @@
|
||||
endpoints:
|
||||
auth: "https://cognito-idp.ap-northeast-1.amazonaws.com/"
|
||||
base: "https://www.telasa.jp/videos/{id}"
|
||||
series: "https://api-videopass.kddi-video.com/v3/series/{id}"
|
||||
episode: "https://api-videopass.kddi-video.com/v3/batch/query"
|
||||
graphql: "https://playback.kddi-video.com/graphql"
|
||||
license:
|
||||
fairplay: "https://license.kddi-video.com/"
|
||||
playready: "https://license.kddi-video.com/rightsmanager.asmx"
|
||||
widevine: "https://license.kddi-video.com/"
|
||||
Loading…
Reference in New Issue
Block a user