Compare commits

...

5 Commits

2 changed files with 53 additions and 109 deletions

View File

@ -4,6 +4,8 @@ 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
@ -28,7 +30,8 @@ from devine.core.manifests import HLS, DASH
class CR(Service):
"""
Service code for Crunchyroll
Written by TPD94
Written by TPD94 and Toonshub, rewritten by an actual dev
Authorization: None (Free) | Cookies (Free and Paid Titles)
Security: L3 FHD
@ -53,6 +56,10 @@ 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
@ -126,7 +133,7 @@ class CR(Service):
# Send a post request to the auth login
response = login_session.post(url='https://www.crunchyroll.com/auth/v1/token', data=data, headers=headers)
response = login_session.post(url=self.config['endpoints']['auth_url'], data=data, headers=headers)
# Retrieve the token from the response
token = f"Bearer {response.json()['access_token']}"
@ -140,7 +147,6 @@ 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)}',
@ -150,22 +156,16 @@ class CR(Service):
self.session.headers.update(headers)
# Get the metadata needed for the series
series_metadata = self.session.get(url=f'https://www.crunchyroll.com/content/v2/cms/series/{self.title}/seasons')
series_metadata = self.session.get(url=self.config['endpoints']['series_metadata'].format(title=self.title)).json()
# Set an empty list for the seasons ids
season_ids = []
# Iterate through the count of seasons reported by the metadata
for seies_seasons in range(series_metadata.json()['total']):
for season in range(series_metadata['total']):
# Get the season id
season_data = series_metadata.json()['data'][seies_seasons]
# 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"] ][0]
else:
season_id = season_id['id']
season_id = series_metadata['data'][season]['id']
# Append it to the season ids list
season_ids.append(season_id)
@ -175,46 +175,20 @@ class CR(Service):
# Get the episode metadata by iterating through each season id
for season in season_ids:
episode_metadata = self.session.get(url=f'https://www.crunchyroll.com/content/v2/cms/seasons/{season}/episodes')
episode_metadata = self.session.get(url=self.config['endpoints']['episode_metadata'].format(season=season)).json()
# Iterate through the total number of episodes and grab metadata
for episode in range(episode_metadata.json()['total']):
# Get the id
episode_id = episode_metadata.json()['data'][episode]['id']
# Get the episode season title
episode_season_title = episode_metadata.json()['data'][episode]['season_title']
# Get the season
episode_season = episode_metadata.json()['data'][episode]['season_number']
# Get the episode number
episode_number = math.ceil(episode_metadata.json()['data'][episode]['sequence_number'])
# Get the episode name
episode_name = episode_metadata.json()['data'][episode]['title']
# Get the episode year
timestamp = episode_metadata.json()['data'][episode]['episode_air_date']
if "Z" in timestamp:
timestamp = timestamp.replace("Z", "")
date_time = datetime.datetime.fromisoformat(timestamp)
episode_year = date_time.year
# 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__)
# Check whether you can stream the title or not
if self.no_login and not episode_metadata.json()['data'][episode].get('streams_link'):
logging.getLogger("CR").warning(f"Media ID: {episode_id} is Premium Only")
continue
# Append the stream links into a list
episode_class.streams = episode_metadata.json()['data'][0]['versions'] or []
# Append it to the list
episodes.append(episode_class)
# 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']]
# Return the episodes as a Series object
return Series(episodes)
@ -233,59 +207,34 @@ class CR(Service):
tracks = Tracks()
# Get the MPD info for each stream
for stream in title.streams:
# Get the MPD info
media_guid = stream["media_guid"]
episode_id = stream['guid']
mpd_info = self.session.get(f'https://www.crunchyroll.com/content/v2/cms/videos/{media_guid}/streams')
mpd_info = self.session.get(url=self.config['endpoints']['mpd_url'].format(id=title.data)).json()
# 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.json()['data'][0]['drm_adaptive_dash']['']['url']
mpd_lang = mpd_info.json()['meta']['audio_locale']
# Get the MPD URL
mpd_url = mpd_info['data'][0]['drm_adaptive_dash']['']['url']
mpd_lang = mpd_info['meta']['audio_locale']
# Check whether MPD has original lang
original_vid = [ v["media_guid"] for v in mpd_info.json()['meta']['versions'] if v['original']][0]
is_original = original_vid == mpd_info.json()['meta']['media_id']
# Grab the tracks from the MPD
tracks = DASH.from_url(url=mpd_url).to_tracks(language=mpd_lang)
# 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.
)
# 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.
)
)
# Return the tracks
return tracks
# Defining a function to get chapters
def get_chapters(self, title):
chapters_data = requests.get(f"https://static.crunchyroll.com/skip-events/production/{title.id}.json")
chapters_data = requests.get(self.config['endpoints']['chapters_url'].format(id=title.id))
# When Chapters are missing, it returns Access Denied XML Page.
if "Access Denied" in chapters_data.text:
@ -329,7 +278,7 @@ class CR(Service):
self.session.headers.update(headers)
# Send a request to get the video token
get_token = self.session.get(f'https://cr-play-service.prd.crunchyrollsvc.com/v1/{list_video_id[0]}/web/firefox/play')
get_token = self.session.get(url=self.config['endpoints']['video_token'].format(id=list_video_id[0]))
# Check for errors
if get_token.json().get('error'):
@ -347,21 +296,14 @@ class CR(Service):
# 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 Video Token
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',
'Referer': 'https://static.crunchyroll.com/',
'Authorization': f'{jwtToken}',
'Authorization': f'{self.authenticate(cookies=self.session.cookies)}',
'content-type': 'application/octet-stream',
'x-cr-content-id': f'{episode_id}',
'x-cr-video-token': f'{video_token}',
'x-cr-content-id': f'{title.id}',
'x-cr-video-token': f'{self.get_video_token(video_id={title.id})}',
'Origin': 'https://static.crunchyroll.com',
}
@ -371,11 +313,5 @@ class CR(Service):
# Send the post request to the license server
license = self.session.post(url=crunchyroll_license_server, data=challenge)
# Close session to avoid TOO_MANY_ACTIVE_STREAMS error
self.session.delete(
f'https://cr-play-service.prd.crunchyrollsvc.com/v1/token/{episode_id}/{video_token}'
)
# Return the license
return license.json()['license']

View File

@ -0,0 +1,8 @@
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
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