CR service update

- Cleaner code
- Added all lost features lost during the rewrite
- Neat config
master
ToonsHub 2024-03-07 16:52:55 +01:00
parent 1fdd4c1834
commit 5a5aed1169
2 changed files with 167 additions and 78 deletions

View File

@ -4,37 +4,28 @@ import math
import time
import datetime
import logging
import re
from abc import ABCMeta, abstractmethod
from http.cookiejar import CookieJar
from typing import Optional, Union
from urllib.parse import urlparse
import click
import requests
from requests.adapters import HTTPAdapter, Retry
from rich.padding import Padding
from rich.rule import Rule
from devine.core.service import Service
from devine.core.titles import Series, Episode
from devine.core.cacher import Cacher
from devine.core.config import config
from devine.core.console import console
from devine.core.constants import AnyTrack
from devine.core.credential import Credential
from devine.core.titles import Title_T, Titles_T
from devine.core.tracks import Video, Audio, Subtitle, Chapter, Chapters, Tracks
from devine.core.utilities import get_ip_info
from devine.core.tracks import Subtitle, Chapter, Tracks
from devine.core.manifests import HLS, DASH
class CR(Service):
"""
Service code for Crunchyroll
Written by TPD94 and Toonshub, rewritten by an actual dev
Written by TPD94 & ToonsHub
Authorization: None (Free) | Cookies (Free and Paid Titles)
Security: L3 FHD
Security: FHD@L3
"""
# Static method, this method belongs to the class
@ -56,10 +47,6 @@ class CR(Service):
# Pass the series_id argument to self so it's accessable across all methods
self.title = title
result = re.match(r"^https?://(?:www\.|beta\.)?crunchyroll\.com/series/(?P<id>[A-Z0-9]+)", self.title)
if result:
self.title = result.group(1)
self.no_login = False
self.token = None
self.token_expiry = 0
@ -68,7 +55,7 @@ class CR(Service):
super().__init__(ctx)
# Defining an authinticate function
# Defining an authenticate function
def authenticate(self, cookies: Optional[CookieJar], credential: Optional[Credential] = None):
# Check for cached token
@ -94,19 +81,20 @@ class CR(Service):
# Add cookies for self sessions later
self.session.cookies.update(cookies)
# Set default headers for the token request
headers = {
'User-Agent': self.config['browser']['user_agent'],
'ETP-Anonymous-ID': f'{uuid.uuid4()}', # Device ID, can be a randomized UUID
'Origin': 'https://www.crunchyroll.com', # Crunchyroll origin
'Referer': 'https://www.crunchyroll.com/', # Crunchyroll referer
}
# If cookies are not available, log in anonymously
if self.no_login:
# Set headers for the token request
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0',
# Setting to Firefox
"Authorization":"Basic Y3Jfd2ViOg==", # Seems to be the same across all browsers on PC
'ETP-Anonymous-ID': f'{uuid.uuid4()}', # Device ID, can be a randomized UUID
'Origin': 'https://www.crunchyroll.com', # Crunchyroll origin
'Referer': 'https://www.crunchyroll.com/', # Crunchyroll referer
}
# Set Authorization for the token request
headers["Authorization"] = "Basic Y3Jfd2ViOg==" # Seems to be the same across all browsers on PC
# Set data for the token request
data = {
@ -114,20 +102,13 @@ class CR(Service):
}
else:
# Set headers for the token request
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0',
# Setting to Firefox
'Authorization': 'Basic bm9haWhkZXZtXzZpeWcwYThsMHE6', # Seems to be the same across all browsers on PC
'ETP-Anonymous-ID': f'{uuid.uuid4()}', # Device ID, can be a randomized UUID
'Origin': 'https://www.crunchyroll.com', # Crunchyroll origin
'Referer': 'https://www.crunchyroll.com/', # Crunchyroll referer
}
# Set Authorization for the token request
headers['Authorization'] = 'Basic bm9haWhkZXZtXzZpeWcwYThsMHE6' # Seems to be the same across all browsers on PC
# Set data for the token request
data = {
'device_id': f'{uuid.uuid4()}', # Device ID, can be randomized UUID
'device_type': 'Firefox on Windows', # Setting to FireFox
'device_type': self.config['browser']['device_type'],
'grant_type': 'etp_rt_cookie', # Seems some sort of refresh token
}
@ -147,6 +128,7 @@ class CR(Service):
# Defining a function to return titles
def get_titles(self) -> Series:
# Set the auth header
headers = {
'Authorization': f'{self.authenticate(cookies=self.session.cookies)}',
@ -162,10 +144,17 @@ class CR(Service):
season_ids = []
# Iterate through the count of seasons reported by the metadata
for season in range(series_metadata['total']):
for season_data in series_metadata['data']:
# Get the season id
season_id = series_metadata['data'][season]['id']
# Attempt to get season IDs for the version with original language.
if season_data.get("versions"):
season_id = [ s["guid"] for s in season_data["versions"] if s["original"] ]
if len(season_id) > 0:
season_id = season_id[0]
else:
season_id = season_data['id']
else:
season_id = season_data['id']
# Append it to the season ids list
season_ids.append(season_id)
@ -177,18 +166,52 @@ class CR(Service):
for season in season_ids:
episode_metadata = self.session.get(url=self.config['endpoints']['episode_metadata'].format(season=season)).json()
# Iterate through each episode and grabs episode data
episodes = [
Episode(
id_=episode['id'],
title=episode['season_title'],
season=episode['season_number'],
number=episode['sequence_number'],
name=episode['title'],
year=episode['episode_air_date'][:4],
service=self.__class__,
data=episode['streams_link']
) for episode in episode_metadata['data']]
# Iterate through the total number of episodes and grab metadata
for episode in episode_metadata['data']:
# Get the id
episode_id = episode['id']
# Get the episode season title
episode_season_title = episode['season_title']
# Get the season
episode_season = episode['season_number']
# Get the episode number
episode_number = math.ceil(episode['sequence_number'])
# Get the episode name
episode_name = episode['title']
# Get the episode year
timestamp = episode['episode_air_date']
if "Z" in timestamp:
timestamp = timestamp.replace("Z", "")
date_time = datetime.datetime.fromisoformat(timestamp)
episode_year = date_time.year
# Check whether you can stream the title or not
if self.no_login and not episode.get('streams_link'):
logging.getLogger("CR").warning(f"Media ID: {episode_id} is Premium Only")
continue
# Set a class for each episode
episode_class = Episode(id_=episode_id, title=episode_season_title, season=episode_season, number=episode_number, name=episode_name, year=episode_year, service=self.__class__)
# Append the stream links into a list
episode_class.streams = episode['versions'] or []
if len(episode_class.streams) == 0:
episode_class.streams = [
{
"audio_locale": episode['audio_locale'],
"guid": episode['id'],
"media_guid": episode['streams_link'].split("/")[-2]
}
]
# Append it to the list
episodes.append(episode_class)
# Return the episodes as a Series object
return Series(episodes)
@ -207,27 +230,56 @@ class CR(Service):
tracks = Tracks()
# Get the MPD info for each stream
for stream in title.streams:
mpd_info = self.session.get(url=self.config['endpoints']['mpd_url'].format(id=title.data)).json()
# Get the MPD info
episode_id = stream['guid']
mpd_info = self.session.get(self.config['endpoints']['mpd_api'].format(id=stream["media_guid"]))
# Check for error HTML pages
try: mpd_info.json()
except:
logging.getLogger("CR").warning(f"Unable to get streams for Media ID: {episode_id}")
continue
# Get the MPD URL
mpd_url = mpd_info['data'][0]['drm_adaptive_dash']['']['url']
mpd_lang = mpd_info['meta']['audio_locale']
# Get the MPD URL
mpd_url = mpd_info.json()['data'][0]['drm_adaptive_dash']['']['url']
mpd_lang = mpd_info.json()['meta']['audio_locale']
# Grab the tracks from the MPD
tracks = DASH.from_url(url=mpd_url).to_tracks(language=mpd_lang)
# Check whether MPD has original lang
versions = mpd_info.json()['meta']['versions'] or [{}]
original_versions = [ v["media_guid"] for v in versions if v.get('original') ]
if len(original_versions) > 0:
original_vid = original_versions[0]
is_original = original_vid == mpd_info.json()['meta']['media_id']
else:
is_original = False
# Get subtitles
for _, sub in mpd_info['meta']['subtitles'].items():
tracks.add(
Subtitle(
url=sub['url'],
codec=Subtitle.Codec.from_mime(sub['format']),
language=sub['locale'],
forced=(sub['locale'] == mpd_lang), # If audio language matches subtitle language, it's Forced.
# Grab the tracks from the MPD
mpd_tracks = DASH.from_url(url=mpd_url).to_tracks(language=mpd_lang)
# Only save the video stream from the first MPD
if len(tracks.videos) == 0:
for video_track in mpd_tracks.videos:
video_track.data.update({"episode_id": stream['guid'], "media_id": stream['media_guid']})
tracks.add(video_track)
# Save audio tracks
for audio_track in mpd_tracks.audio:
audio_track.is_original_lang = is_original
audio_track.data.update({"episode_id": stream['guid'], "media_id": stream['media_guid']})
tracks.add(audio_track)
# Get subtitles
for _, sub in mpd_info.json()['meta']['subtitles'].items():
tracks.add(
Subtitle(
url=sub['url'],
codec=Subtitle.Codec.from_mime(sub['format']),
language=sub['locale'],
forced=(sub['locale'] == mpd_lang), # If audio language matches subtitle language, it's Forced.
)
)
)
# Return the tracks
return tracks
@ -278,7 +330,9 @@ class CR(Service):
self.session.headers.update(headers)
# Send a request to get the video token
get_token = self.session.get(url=self.config['endpoints']['video_token'].format(id=list_video_id[0]))
get_token = self.session.get(
self.config['endpoints']['video_token'].format(id=list_video_id[0], browser=self.config['browser']['browser_name'])
)
# Check for errors
if get_token.json().get('error'):
@ -289,21 +343,46 @@ class CR(Service):
# Return the token
return token
# Function to close all sessions before getting license
def close_all_sessions(self):
# Get all sessions
headers = {
"authorization": f"Bearer {self.authenticate(cookies=self.session.cookies)}"
}
response = requests.get(self.config['endpoints']['open_sessions'], headers=headers)
# Close all sessions
for ses in response.json()['items']:
self.close_session(ses['contentId'], ses['token'])
# Function to close a watch session
def close_session(self, id, token):
# Close session
self.session.delete(
self.config['endpoints']['delete_session'].format(id=id, token=token)
)
# Defining a function to get widevine license keys
def get_widevine_license(self, *, challenge: bytes, title: Title_T, track: AnyTrack) -> Optional[Union[bytes, str]]:
# Set the license server
crunchyroll_license_server = 'https://cr-license-proxy.prd.crunchyrollsvc.com/v1/license/widevine'
# Get the Episode ID
episode_id = track.data["episode_id"]
# Get the Tokens
video_token = self.get_video_token(video_id={episode_id})
jwtToken = self.authenticate(cookies=self.session.cookies)
# Set the headers
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0',
'User-Agent': self.config['browser']['user_agent'],
'Referer': 'https://static.crunchyroll.com/',
'Authorization': f'{self.authenticate(cookies=self.session.cookies)}',
'Authorization': f'{jwtToken}',
'content-type': 'application/octet-stream',
'x-cr-content-id': f'{title.id}',
'x-cr-video-token': f'{self.get_video_token(video_id={title.id})}',
'x-cr-content-id': f'{episode_id}',
'x-cr-video-token': f'{video_token}',
'Origin': 'https://static.crunchyroll.com',
}
@ -311,7 +390,10 @@ class CR(Service):
self.session.headers.update(headers)
# Send the post request to the license server
license = self.session.post(url=crunchyroll_license_server, data=challenge)
license = self.session.post(url=self.config['endpoints']['license_url'], data=challenge)
# Close session to avoid TOO_MANY_ACTIVE_STREAMS error
self.close_session(episode_id, video_token)
# Return the license
return license.json()['license']
return license.json()['license']

View File

@ -1,8 +1,15 @@
endpoints:
episode_metadata: https://www.crunchyroll.com/content/v2/cms/seasons/{season}/episodes
series_metadata: https://www.crunchyroll.com/content/v2/cms/series/{title}/seasons
mpd_url: https://www.crunchyroll.com/{id}
video_token: https://cr-play-service.prd.crunchyrollsvc.com/v1/{id}/web/firefox/play
mpd_api: https://www.crunchyroll.com/content/v2/cms/videos/{id}/streams
video_token: https://cr-play-service.prd.crunchyrollsvc.com/v1/{id}/web/{browser}/play
license_url: https://cr-license-proxy.prd.crunchyrollsvc.com/v1/license/widevine
auth_url: https://www.crunchyroll.com/auth/v1/token
chapters_url: https://static.crunchyroll.com/skip-events/production/{id}.json
chapters_url: https://static.crunchyroll.com/skip-events/production/{id}.json
open_sessions: https://cr-play-service.prd.crunchyrollsvc.com/v1/sessions/streaming
delete_session: https://cr-play-service.prd.crunchyrollsvc.com/v1/token/{id}/{token}
browser:
user_agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0
browser_name: firefox
device_type: Firefox on Windows